├── VERSION.md ├── .ci-operator.yaml ├── pkg ├── ocm │ └── ocm_suite_test.go ├── ai │ └── mcp │ │ ├── mcp_suite_test.go │ │ ├── backplane_login.go │ │ ├── backplane_info.go │ │ ├── backplane_cloud_console.go │ │ └── backplane_console.go ├── awsutil │ ├── aws_suite_test.go │ ├── iam.go │ └── iam_test.go ├── login │ ├── login_suite_test.go │ ├── kubeConfig_test.go │ └── printClusterInfo.go ├── info │ ├── info_suite_test.go │ ├── buildInfo.go │ ├── info_test.go │ ├── mocks │ │ ├── infoMock.go │ │ └── buildInfoMock.go │ └── info.go ├── jira │ ├── jira_suite_test.go │ ├── ohssService.go │ ├── ohssService_test.go │ └── issueService.go ├── pagerduty │ ├── pagerduty_suite_test.go │ ├── client.go │ └── mocks │ │ └── clientMock.go ├── healthcheck │ ├── healthcheck_suite_test.go │ ├── mocks │ │ ├── httpClientMock.go │ │ └── networkMock.go │ ├── check_vpn.go │ └── check_proxy.go ├── backplaneapi │ ├── backplaneapi_suite_test.go │ ├── deprecation.go │ └── deprecation_test.go ├── credentials │ ├── credentials.go │ ├── gcp.go │ └── aws.go ├── cli │ ├── session │ │ ├── session_suite_test.go │ │ └── mocks │ │ │ └── sessionMock.go │ └── globalflags │ │ ├── globalflags.go │ │ └── logs.go ├── monitoring │ └── monitoring_suite_test.go ├── utils │ ├── shell.go │ ├── shell_test.go │ ├── jwt.go │ ├── mocks │ │ └── shellCheckerMock.go │ ├── util_test.go │ ├── cluster.go │ └── renderingutils.go ├── container │ ├── factory.go │ ├── container.go │ └── common.go ├── ssm │ └── mocks │ │ └── mock_ssmclient.go ├── elevate │ └── elevate.go └── remediation │ └── remediation.go ├── cmd └── ocm-backplane │ ├── root_suite_test.go │ ├── login │ └── login_suite_test.go │ ├── mcp │ ├── mcp_suite_test.go │ └── mcp.go │ ├── config │ ├── config_suite_test.go │ ├── config.go │ ├── get.go │ ├── set.go │ └── troubleshoot.go │ ├── logout │ ├── logout_suite_test.go │ └── logout.go │ ├── cloud │ ├── cloud_suite_test.go │ ├── cloud.go │ └── console_test.go │ ├── script │ ├── script_suite_test.go │ ├── script.go │ └── listScripts.go │ ├── testJob │ ├── testJob_suite_test.go │ ├── testJob.go │ ├── getTestJobLogs.go │ └── getTestJob.go │ ├── managedJob │ ├── managedJob_suite_test.go │ ├── managedJob.go │ ├── logsManagedJob.go │ ├── deleteManagedJob.go │ └── getManagedJob.go │ ├── version │ └── version.go │ ├── root_test.go │ ├── main.go │ ├── accessrequest │ ├── accessRequest.go │ ├── getAccessRequest.go │ ├── expireAccessRequest.go │ └── createAccessRequest.go │ ├── healthcheck │ └── health_check.go │ ├── upgrade │ └── upgrade.go │ ├── elevate │ └── elevate.go │ ├── status │ └── status.go │ ├── session │ └── session.go │ ├── monitoring │ └── monitoring.go │ └── root.go ├── internal ├── github │ └── options.go └── upgrade │ ├── options.go │ └── writer.go ├── .gitignore ├── .codecov.yml ├── make.Dockerfile ├── OWNERS ├── .golangci.yml ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── branch-protection-check.yml │ └── dependabot-auto-merge.yml ├── docs ├── FAQ.md ├── hotfix.md ├── PS1-setup.md └── AUTO_MERGE_SETUP.md ├── .goreleaser.yaml ├── ci-release.sh ├── hack └── codecov.sh ├── CLAUDE.md └── Dockerfile /VERSION.md: -------------------------------------------------------------------------------- 1 | # Release Version Test 2 | Version: 0.1.32 3 | -------------------------------------------------------------------------------- /.ci-operator.yaml: -------------------------------------------------------------------------------- 1 | build_root_image: 2 | name: builder 3 | namespace: ocp 4 | tag: rhel-9-golang-1.24-openshift-4.16 5 | -------------------------------------------------------------------------------- /pkg/ocm/ocm_suite_test.go: -------------------------------------------------------------------------------- 1 | package ocm 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestIt(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "OCM Test Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/ai/mcp/mcp_suite_test.go: -------------------------------------------------------------------------------- 1 | package mcp_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestMCP(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "MCP Suite") 13 | } 14 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/root_suite_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestIt(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Root Test Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/awsutil/aws_suite_test.go: -------------------------------------------------------------------------------- 1 | package awsutil 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestIt(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "AWS Util Test Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/login/login_suite_test.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestIt(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Login pkg test suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/info/info_suite_test.go: -------------------------------------------------------------------------------- 1 | package info_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestInfo(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Info Service Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/jira/jira_suite_test.go: -------------------------------------------------------------------------------- 1 | package jira_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestJira(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Jira Service Suite") 13 | } 14 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/login/login_suite_test.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestIt(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Login Test Suite") 13 | } 14 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/mcp/mcp_suite_test.go: -------------------------------------------------------------------------------- 1 | package mcp_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestMCP(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "MCP Command Suite") 13 | } 14 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/config/config_suite_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestIt(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Config Test Suite") 13 | } 14 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/logout/logout_suite_test.go: -------------------------------------------------------------------------------- 1 | package logout 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestIt(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Logout Test Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/pagerduty/pagerduty_suite_test.go: -------------------------------------------------------------------------------- 1 | package pagerduty_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestPagerduty(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Pagerduty Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/healthcheck/healthcheck_suite_test.go: -------------------------------------------------------------------------------- 1 | package healthcheck_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestHealthCheck(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Health Check Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/backplaneapi/backplaneapi_suite_test.go: -------------------------------------------------------------------------------- 1 | package backplaneapi_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestBackplaneapi(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Backplaneapi Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/credentials/credentials.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | type Response interface { 4 | // String returns a friendly message outlining how users can setup cloud environment access 5 | String() string 6 | 7 | // FmtExport sets environment variables for users to export to setup cloud environment access 8 | FmtExport() string 9 | } 10 | -------------------------------------------------------------------------------- /internal/github/options.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "net/http" 4 | 5 | type WithBaseURL string 6 | 7 | func (w WithBaseURL) ConfigureClient(c *ClientConfig) { 8 | c.BaseURL = string(w) 9 | } 10 | 11 | type WithClient http.Client 12 | 13 | func (w WithClient) ConfigureClient(c *ClientConfig) { 14 | c.Client = http.Client(w) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/cli/session/session_suite_test.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestIt(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | RunSpecs(t, "Session Test Suite") 15 | } 16 | 17 | func MakeIoReader(s string) io.ReadCloser { 18 | r := io.NopCloser(strings.NewReader(s)) 19 | return r 20 | } 21 | -------------------------------------------------------------------------------- /pkg/backplaneapi/deprecation.go: -------------------------------------------------------------------------------- 1 | package backplaneapi 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | ) 7 | 8 | const deprecationMsg = "server indicated that this client is deprecated" 9 | 10 | var ErrDeprecation = errors.New(deprecationMsg) 11 | 12 | func CheckResponseDeprecation(r *http.Response) error { 13 | if r.Header.Get("Deprecated-Client") == "true" { 14 | return ErrDeprecation 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /pkg/monitoring/monitoring_suite_test.go: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestIt(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | RunSpecs(t, "Monitoring Test Suite") 15 | } 16 | 17 | func MakeIoReader(s string) io.ReadCloser { 18 | r := io.NopCloser(strings.NewReader(s)) 19 | return r 20 | } 21 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/cloud/cloud_suite_test.go: -------------------------------------------------------------------------------- 1 | package cloud 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestIt(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | RunSpecs(t, "Cloud Test Suite") 15 | } 16 | 17 | func MakeIoReader(s string) io.ReadCloser { 18 | r := io.NopCloser(strings.NewReader(s)) // r type is io.ReadCloser 19 | return r 20 | } 21 | -------------------------------------------------------------------------------- /pkg/utils/shell.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "os" 4 | 5 | var ShellChecker ShellCheckerInterface = DefaultShellChecker{} 6 | 7 | type ShellCheckerInterface interface { 8 | IsValidShell(shellPath string) bool 9 | } 10 | 11 | type DefaultShellChecker struct{} 12 | 13 | // Helper function to check if a shell is valid 14 | func (checker DefaultShellChecker) IsValidShell(shellPath string) bool { 15 | _, err := os.Stat(shellPath) 16 | return err == nil 17 | } 18 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/script/script_suite_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestScriptCmdSuite(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | RunSpecs(t, "ManagedJob Test Suite") 15 | } 16 | 17 | func MakeIoReader(s string) io.ReadCloser { 18 | r := io.NopCloser(strings.NewReader(s)) // r type is io.ReadCloser 19 | return r 20 | } 21 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/testJob/testJob_suite_test.go: -------------------------------------------------------------------------------- 1 | package testjob 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestTestJobCmdSuite(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | RunSpecs(t, "testJob Test Suite") 15 | } 16 | 17 | func MakeIoReader(s string) io.ReadCloser { 18 | r := io.NopCloser(strings.NewReader(s)) // r type is io.ReadCloser 19 | return r 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | vendor/ 16 | 17 | # Goreleaser 18 | dist/ 19 | 20 | # backplane-cli binary 21 | backplane-cli 22 | /ocm-backplane 23 | 24 | # IDEs 25 | .vscode 26 | .idea 27 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/managedJob/managedJob_suite_test.go: -------------------------------------------------------------------------------- 1 | package managedjob 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | "testing" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | ) 11 | 12 | func TestManagedJobCmdSuite(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | RunSpecs(t, "ManagedJob Test Suite") 15 | } 16 | 17 | func MakeIoReader(s string) io.ReadCloser { 18 | r := io.NopCloser(strings.NewReader(s)) // r type is io.ReadCloser 19 | return r 20 | } 21 | -------------------------------------------------------------------------------- /pkg/info/buildInfo.go: -------------------------------------------------------------------------------- 1 | package info 2 | 3 | import "runtime/debug" 4 | 5 | type BuildInfoService interface { 6 | // return the BuildInfo from Go build 7 | GetBuildInfo() (info *debug.BuildInfo, ok bool) 8 | } 9 | 10 | type DefaultBuildInfoServiceImpl struct { 11 | } 12 | 13 | func (b *DefaultBuildInfoServiceImpl) GetBuildInfo() (info *debug.BuildInfo, ok bool) { 14 | return debug.ReadBuildInfo() 15 | } 16 | 17 | var DefaultBuildInfoService BuildInfoService = &DefaultBuildInfoServiceImpl{} 18 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: no 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: "20...100" 9 | 10 | status: 11 | project: no 12 | patch: no 13 | changes: no 14 | 15 | parsers: 16 | gcov: 17 | branch_detection: 18 | conditional: yes 19 | loop: yes 20 | method: no 21 | macro: no 22 | 23 | comment: 24 | layout: "reach,diff,flags,tree" 25 | behavior: default 26 | require_changes: no 27 | 28 | ignore: 29 | - "**/mocks" 30 | - "**/mocks/**" 31 | -------------------------------------------------------------------------------- /make.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GOLANGCI_LINT_VERSION 2 | 3 | FROM registry.access.redhat.com/ubi8/ubi 4 | 5 | RUN yum install -y ca-certificates git go-toolset make 6 | 7 | ENV GOPATH=/go 8 | ENV PATH="$GOPATH/bin:${PATH}" 9 | RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b ${GOPATH}/bin ${GOLANGCI_LINT_VERSION} 10 | 11 | ADD https://password.corp.redhat.com/RH-IT-Root-CA.crt /etc/pki/ca-trust/source/anchors/ 12 | RUN update-ca-trust extract 13 | 14 | RUN go install github.com/golang/mock/mockgen@v1.6.0 15 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | reviewers: 2 | - wanghaoran1988 3 | - feichashao 4 | - MitaliBhalla 5 | - Tafhim 6 | - bmeng 7 | - samanthajayasinghe 8 | - xiaoyu74 9 | - typeid 10 | - Tessg22 11 | - smarthall 12 | - diakovnec 13 | - a7vicky 14 | 15 | approvers: 16 | - wanghaoran1988 17 | - feichashao 18 | - bmeng 19 | - typeid 20 | - samanthajayasinghe 21 | - xiaoyu74 22 | - MitaliBhalla 23 | - smarthall 24 | - diakovnec 25 | 26 | maintainers: 27 | - wanghaoran1988 28 | - feichashao 29 | - MitaliBhalla 30 | - bmeng 31 | - samanthajayasinghe 32 | - xiaoyu74 33 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/openshift/backplane-cli/pkg/info" 10 | ) 11 | 12 | // VersionCmd represents the version command 13 | var VersionCmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "Prints the version", 16 | Long: `Display the version of Backplane CLI`, 17 | RunE: runVersion, 18 | } 19 | 20 | func runVersion(cmd *cobra.Command, argv []string) error { 21 | // Print the version 22 | _, _ = fmt.Fprintf(os.Stdout, "%s\n", info.DefaultInfoService.GetVersion()) 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | modules-download-mode: readonly 4 | linters: 5 | default: none 6 | enable: 7 | - errcheck 8 | - gosec 9 | - govet 10 | - ineffassign 11 | - staticcheck 12 | - unused 13 | exclusions: 14 | generated: lax 15 | presets: 16 | - comments 17 | - common-false-positives 18 | - legacy 19 | - std-error-handling 20 | paths: 21 | - third_party$ 22 | - builtin$ 23 | - examples$ 24 | formatters: 25 | exclusions: 26 | generated: lax 27 | paths: 28 | - third_party$ 29 | - builtin$ 30 | - examples$ 31 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/root_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("Backplane commands", func() { 9 | Context("Test root cmd", func() { 10 | 11 | It("Check root cmd help ", func() { 12 | err := rootCmd.Help() 13 | Expect(err).To(BeNil()) 14 | }) 15 | 16 | It("Check verbosity persistent flag", func() { 17 | flagSet := rootCmd.PersistentFlags() 18 | verbosityFlag := flagSet.Lookup("verbosity") 19 | Expect(verbosityFlag).NotTo(BeNil()) 20 | 21 | // check the deafult log level 22 | Expect(verbosityFlag.DefValue).To(Equal("warning")) 23 | 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/cloud/cloud.go: -------------------------------------------------------------------------------- 1 | package cloud 2 | 3 | import ( 4 | bpconfig "github.com/openshift/backplane-cli/pkg/cli/config" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var GetBackplaneConfiguration = bpconfig.GetBackplaneConfiguration 9 | 10 | var CloudCmd = &cobra.Command{ 11 | Use: "cloud", 12 | Short: "Cluster cloud provider access", 13 | Args: cobra.NoArgs, 14 | DisableAutoGenTag: true, 15 | Run: help, 16 | } 17 | 18 | func init() { 19 | CloudCmd.AddCommand(CredentialsCmd) 20 | CloudCmd.AddCommand(ConsoleCmd) 21 | CloudCmd.AddCommand(SSMSessionCmd) 22 | } 23 | 24 | func help(cmd *cobra.Command, _ []string) { 25 | _ = cmd.Help() 26 | } 27 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | Copyright © 2020 Red Hat, Inc. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | func main() { 20 | Execute() 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[RFE]' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /pkg/credentials/gcp.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | import "fmt" 4 | 5 | const ( 6 | // format strings for printing GCP credentials as a string or as environment variables 7 | gcpCredentialsStringFormat = `If this is your first time, run "gcloud auth login" and then 8 | gcloud config set project %s` 9 | gcpExportFormat = `export CLOUDSDK_CORE_PROJECT=%s` 10 | ) 11 | 12 | type GCPCredentialsResponse struct { 13 | ProjectID string `json:"project_id" yaml:"project_id"` 14 | } 15 | 16 | func (r *GCPCredentialsResponse) String() string { 17 | return fmt.Sprintf(gcpCredentialsStringFormat, r.ProjectID) 18 | } 19 | 20 | func (r *GCPCredentialsResponse) FmtExport() string { 21 | return fmt.Sprintf(gcpExportFormat, r.ProjectID) 22 | } 23 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## Why is the binary named ocm-backplane? 4 | With the name `ocm-*`, the binary can act as an [OCM plugin](https://github.com/openshift-online/ocm-cli/blob/119e9d2e8af0ddf5db4b0888b5ceadb300768885/README.md#extend-ocm-with-plugins). 5 | 6 | The logic behind OCM plugin: 7 | - The user runs `ocm backplane`. 8 | - `ocm` doesn't have the `backplane` subcommand. 9 | - `ocm` looks up the `$PATH` and finds the `ocm-backplane` executable binary. 10 | - `ocm` calls the `ocm-backplane` binary. 11 | 12 | ## Does ocm-backplane depend on a specific OCM binary version? 13 | `ocm-backplane` is a standalone binary. It is functional even without the `ocm` binaries. The interaction with OCM API will be handled by OCM SDK, which is defined as a dependency of the project. 14 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/accessrequest/accessRequest.go: -------------------------------------------------------------------------------- 1 | package accessrequest 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func NewAccessRequestCmd() *cobra.Command { 8 | cmd := &cobra.Command{ 9 | Use: "accessrequest", 10 | Aliases: []string{"accessRequest", "accessrequest", "accessrequests"}, 11 | Short: "Manages access requests for clusters on which access protection is enabled", 12 | SilenceUsage: true, 13 | } 14 | 15 | // cluster-id Flag 16 | cmd.PersistentFlags().StringP("cluster-id", "c", "", "Cluster ID could be cluster name, id or external-id") 17 | 18 | cmd.AddCommand(newCreateAccessRequestCmd()) 19 | cmd.AddCommand(newGetAccessRequestCmd()) 20 | cmd.AddCommand(newExpireAccessRequestCmd()) 21 | 22 | return cmd 23 | } 24 | 25 | func init() { 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | # Bug Description 10 | 11 | ## **Output of `ocm-backplane` version** 12 | 13 | ## **Describe the bug** 14 | 15 | A clear and concise description of what the bug is. 16 | 17 | ## **To Reproduce** 18 | 19 | Steps to reproduce the behavior: 20 | 21 | 1. Go to '...' 22 | 1. Scroll down to '....' 23 | 1. See error 24 | 25 | ## **Expected behavior** 26 | 27 | A clear and concise description of what you expected to happen. 28 | 29 | ## **Screenshots or output** 30 | 31 | If applicable, add screenshots or ocm-backplane output to help explain your problem. 32 | 33 | ## **Additional context** 34 | 35 | Add any other context about the problem here -------------------------------------------------------------------------------- /cmd/ocm-backplane/healthcheck/health_check.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "github.com/openshift/backplane-cli/pkg/healthcheck" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var ( 9 | checkVPN bool 10 | checkProxy bool 11 | ) 12 | 13 | // HealthCheckCmd is the command for performing health checks 14 | var HealthCheckCmd = &cobra.Command{ 15 | Use: "healthcheck", 16 | Aliases: []string{"healthCheck", "health-check", "healthchecks"}, 17 | Short: "Check VPN and Proxy connectivity on the localhost", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | healthcheck.RunHealthCheck(checkVPN, checkProxy)(cmd, args) 20 | }, 21 | } 22 | 23 | func init() { 24 | HealthCheckCmd.Flags().BoolVar(&checkVPN, "vpn", false, "Check only VPN connectivity") 25 | HealthCheckCmd.Flags().BoolVar(&checkProxy, "proxy", false, "Check only Proxy connectivity") 26 | } 27 | -------------------------------------------------------------------------------- /pkg/container/factory.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // NewEngine creates a new container engine instance based on the operating system and container engine type. 10 | // Supported combinations: Linux/Podman, macOS/Podman, Linux/Docker, macOS/Docker. 11 | // Returns an error for unsupported combinations. 12 | func NewEngine(osName, containerEngine string) (ContainerEngine, error) { 13 | if osName == LINUX && containerEngine == PODMAN { 14 | return &podmanLinux{fileMountDir: filepath.Join(os.TempDir(), "backplane")}, nil 15 | } else if osName == MACOS && containerEngine == PODMAN { 16 | return &podmanMac{}, nil 17 | } else if osName == LINUX && containerEngine == DOCKER { 18 | return &dockerLinux{}, nil 19 | } else if osName == MACOS && containerEngine == DOCKER { 20 | return &dockerMac{}, nil 21 | } else { 22 | return nil, fmt.Errorf("unsupported container engine: %s/%s", osName, containerEngine) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/upgrade/options.go: -------------------------------------------------------------------------------- 1 | package upgrade 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | type WithLog struct{ Log logrus.FieldLogger } 10 | 11 | func (w WithLog) ConfigureCmd(c *CmdConfig) { 12 | c.Log = w.Log 13 | } 14 | 15 | func (w WithLog) ConfigureSafeWriter(c *SafeWriterConfig) { 16 | c.Log = w.Log 17 | } 18 | 19 | type WithOut struct{ Out io.Writer } 20 | 21 | func (w WithOut) ConfigureCmd(c *CmdConfig) { 22 | c.Out = w.Out 23 | } 24 | 25 | type WithWriter struct{ Writer Writer } 26 | 27 | func (w WithWriter) ConfigureCmd(c *CmdConfig) { 28 | c.Writer = w.Writer 29 | } 30 | 31 | type WithBinaryName string 32 | 33 | func (w WithBinaryName) ConfigureCmd(c *CmdConfig) { 34 | c.BinaryName = string(w) 35 | } 36 | 37 | type WithOrg string 38 | 39 | func (w WithOrg) ConfigureCmd(c *CmdConfig) { 40 | c.Org = string(w) 41 | } 42 | 43 | type WithRepo string 44 | 45 | func (w WithRepo) ConfigureCmd(c *CmdConfig) { 46 | c.Repo = string(w) 47 | } 48 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: ocm-backplane 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | - go generate ./... 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | - "GO111MODULE=on" 12 | - "GOFLAGS=-mod=readonly -trimpath" 13 | goos: 14 | - linux 15 | - darwin 16 | goarch: 17 | - amd64 18 | - arm64 19 | ldflags: 20 | # The "-X" go flag sets the Version 21 | - -X github.com/openshift/backplane-cli/pkg/info.Version={{.Version}} 22 | main: ./cmd/ocm-backplane/ 23 | binary: ocm-backplane 24 | 25 | archives: 26 | - name_template: '{{ .ProjectName }}_{{- .Version }}_{{- title .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end}}' 27 | 28 | checksum: 29 | name_template: "checksums.txt" 30 | 31 | snapshot: 32 | name_template: "{{ .Tag }}-next" 33 | 34 | changelog: 35 | sort: asc 36 | filters: 37 | exclude: 38 | - "^docs:" 39 | - "^test:" 40 | release: 41 | github: 42 | owner: "openshift" 43 | name: "backplane-cli" 44 | -------------------------------------------------------------------------------- /pkg/container/container.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import "os/exec" 4 | 5 | const ( 6 | // DOCKER binary name of docker 7 | DOCKER = "docker" 8 | // PODMAN binary name of podman 9 | PODMAN = "podman" 10 | // Linux name in runtime.GOOS 11 | LINUX = "linux" 12 | // MACOS name in runtime.GOOS 13 | MACOS = "darwin" 14 | ) 15 | 16 | var ( 17 | createCommand = exec.Command 18 | // Pull Secret saving directory 19 | pullSecretConfigDirectory string 20 | ) 21 | 22 | type ContainerEngine interface { 23 | PullImage(imageName string) error 24 | PutFileToMount(filename string, content []byte) error 25 | StopContainer(containerName string) error 26 | RunConsoleContainer(containerName string, port string, consoleArgs []string, envVars []EnvVar) error 27 | RunMonitorPlugin(containerName string, consoleContainerName string, nginxConf string, pluginArgs []string, envVars []EnvVar) error 28 | ContainerIsExist(containerName string) (bool, error) 29 | } 30 | 31 | // EnvVar for environment variable passing to container 32 | type EnvVar struct { 33 | Key string 34 | Value string 35 | } 36 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | const ( 8 | ProxyURLConfigVar = "proxy-url" 9 | URLConfigVar = "url" 10 | SessionConfigVar = "session-dir" 11 | PagerDutyAPIConfigVar = "pd-key" 12 | GovcloudVar = "govcloud" 13 | ) 14 | 15 | func NewConfigCmd() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "config", 18 | Short: "Get or set backplane-cli configuration", 19 | Long: `Get or set backplane-cli configuration variables. 20 | The location of the configuration file is gleaned from ~/.config/backplane/config.json or the 'BACKPLANE_CONFIG' environment variable if set. 21 | 22 | The following variables are supported: 23 | url Backplane API URL 24 | proxy-url Squid proxy URL 25 | session-dir Backplane CLI session directory 26 | pd-key PagerDuty API User Key 27 | govcloud Set to true if used in FedRAMP 28 | `, 29 | SilenceUsage: true, 30 | } 31 | 32 | cmd.AddCommand(newGetCmd()) 33 | cmd.AddCommand(newSetCmd()) 34 | cmd.AddCommand(newTroubleshootCmd()) 35 | return cmd 36 | } -------------------------------------------------------------------------------- /pkg/cli/globalflags/globalflags.go: -------------------------------------------------------------------------------- 1 | package globalflags 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // GlobalOptions defines all available commands 8 | type GlobalOptions struct { 9 | BackplaneURL string 10 | ProxyURL string 11 | Manager bool 12 | Service bool 13 | } 14 | 15 | // AddGlobalFlags adds common global flags to a cobra command. 16 | // These flags include BackplaneURL, ProxyURL, Manager, and Service options. 17 | func AddGlobalFlags(cmd *cobra.Command, opts *GlobalOptions) { 18 | cmd.PersistentFlags().StringVar( 19 | &opts.BackplaneURL, 20 | "url", 21 | "", 22 | "URL of backplane API", 23 | ) 24 | cmd.PersistentFlags().StringVar( 25 | &opts.ProxyURL, 26 | "proxy", 27 | "", 28 | "URL of HTTPS proxy", 29 | ) 30 | cmd.PersistentFlags().BoolVar( 31 | &opts.Manager, 32 | "manager", 33 | false, 34 | "Login to management cluster instead of the cluster itself.", 35 | ) 36 | cmd.PersistentFlags().BoolVar( 37 | &opts.Service, 38 | "service", 39 | false, 40 | "Login to service cluster for the given hosted cluster or management cluster.", 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/testJob/testJob.go: -------------------------------------------------------------------------------- 1 | package testjob 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func NewTestJobCommand() *cobra.Command { 8 | cmd := &cobra.Command{ 9 | Use: "testjob", 10 | Aliases: []string{"testJob", "testjobs", "tj"}, 11 | Short: "Represents a backplane testJob.", 12 | SilenceUsage: true, 13 | Hidden: true, 14 | } 15 | 16 | // url flag 17 | // Denotes backplane url 18 | // If this flag is empty, backplane-url will be fetched by user settings. 19 | cmd.PersistentFlags().String( 20 | "url", 21 | "", 22 | "Specify backplane url.", 23 | ) 24 | 25 | // cluster-id Flag 26 | cmd.PersistentFlags().StringP("cluster-id", "c", "", "Cluster ID could be cluster name, id or external-id") 27 | 28 | // raw Flag 29 | cmd.PersistentFlags().Bool("raw", false, "Prints the raw response returned by the backplane API") 30 | 31 | cmd.PersistentFlags().BoolP("follow", "f", false, "Specify if logs should be streamed") 32 | 33 | cmd.AddCommand( 34 | newCreateTestJobCommand(), 35 | newGetTestJobCommand(), 36 | newGetTestJobLogsCommand(), 37 | ) 38 | 39 | return cmd 40 | } 41 | -------------------------------------------------------------------------------- /pkg/backplaneapi/deprecation_test.go: -------------------------------------------------------------------------------- 1 | package backplaneapi_test 2 | 3 | import ( 4 | "net/http" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | 9 | "github.com/openshift/backplane-cli/pkg/backplaneapi" 10 | ) 11 | 12 | var _ = Describe("backplaneapi/deprecation", func() { 13 | It("Returns ErrDeprecation when the 'Deprecated-Client' header is present.", func() { 14 | r := http.Response{Header: http.Header{}} 15 | 16 | r.Header.Add("Deprecated-Client", "true") 17 | 18 | err := backplaneapi.CheckResponseDeprecation(&r) 19 | 20 | Expect(err).To(Equal(backplaneapi.ErrDeprecation)) 21 | }) 22 | 23 | It("Returns nil when the 'Deprecated-Client' header is not present.", func() { 24 | r := http.Response{Header: http.Header{}} 25 | 26 | err := backplaneapi.CheckResponseDeprecation(&r) 27 | 28 | Expect(err).To(BeNil()) 29 | }) 30 | 31 | It("Returns nil when the 'Deprecated-Client' header is not 'true'.", func() { 32 | r := http.Response{Header: http.Header{}} 33 | 34 | r.Header.Add("Deprecated-Client", "false") 35 | 36 | err := backplaneapi.CheckResponseDeprecation(&r) 37 | 38 | Expect(err).To(BeNil()) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/managedJob/managedJob.go: -------------------------------------------------------------------------------- 1 | package managedjob 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func NewManagedJobCmd() *cobra.Command { 8 | cmd := &cobra.Command{ 9 | Use: "managedjob", 10 | Aliases: []string{"managedJob", "managedjob", "managedjobs"}, 11 | Short: "Backplane managedjob resource which is an instance of Backplane script", 12 | SilenceUsage: true, 13 | } 14 | 15 | // url flag 16 | // Denotes backplane url 17 | // If this flag is empty, backplane-url will be fetched by the user local settings. either via BACKPLANE_URL env or ~/backplane.{env}.json file 18 | cmd.PersistentFlags().String( 19 | "url", 20 | "", 21 | "Specify backplane url.", 22 | ) 23 | 24 | // cluster-id Flag 25 | cmd.PersistentFlags().StringP("cluster-id", "c", "", "Cluster ID could be cluster name, id or external-id") 26 | 27 | // raw Flag 28 | cmd.PersistentFlags().Bool("raw", false, "Prints the raw response returned by the backplane API") 29 | 30 | cmd.AddCommand(newCreateManagedJobCmd(), newGetManagedJobCmd(), newDeleteManagedJobCmd(), newLogsManagedJobCmd()) 31 | 32 | return cmd 33 | } 34 | 35 | func init() { 36 | 37 | } 38 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/upgrade/upgrade.go: -------------------------------------------------------------------------------- 1 | package upgrade 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/openshift/backplane-cli/internal/github" 9 | "github.com/openshift/backplane-cli/internal/upgrade" 10 | "github.com/openshift/backplane-cli/pkg/info" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func long() string { 15 | return strings.Join([]string{ 16 | "Upgrades the latest version release based on", 17 | "your machine's OS and architecture.", 18 | }, " ") 19 | } 20 | 21 | var UpgradeCmd = &cobra.Command{ 22 | Use: "upgrade", 23 | Short: "Upgrade the current backplane-cli to the latest version", 24 | Long: long(), 25 | 26 | RunE: runUpgrade, 27 | Args: cobra.ArbitraryArgs, 28 | 29 | SilenceUsage: true, 30 | } 31 | 32 | func runUpgrade(cmd *cobra.Command, _ []string) error { 33 | 34 | ctx, cancel := context.WithCancel(cmd.Context()) 35 | defer cancel() 36 | 37 | git := github.NewClient() 38 | 39 | if err := git.CheckConnection(); err != nil { 40 | return fmt.Errorf("checking connection to the git server: %w", err) 41 | } 42 | 43 | upgrade := upgrade.NewCmd(git) 44 | 45 | return upgrade.UpgradePlugin(ctx, info.Version) 46 | } 47 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "docker" 4 | directory: "/" 5 | labels: 6 | - "area/dependency" 7 | - "ok-to-test" 8 | schedule: 9 | interval: "weekly" 10 | # Enable auto-merge for patch and minor updates 11 | open-pull-requests-limit: 10 12 | ignore: 13 | - dependency-name: "app-sre/boilerplate" 14 | # don't upgrade boilerplate via these means 15 | - dependency-name: "openshift/origin-operator-registry" 16 | # don't upgrade origin-operator-registry via these means 17 | - package-ecosystem: gomod 18 | directory: "/" 19 | labels: 20 | - "area/dependency" 21 | - "ok-to-test" 22 | schedule: 23 | interval: "weekly" 24 | # Enable auto-merge for patch and minor updates 25 | open-pull-requests-limit: 10 26 | # Group related updates together to reduce PR volume 27 | groups: 28 | aws-sdk: 29 | patterns: 30 | - "github.com/aws/aws-sdk-go-v2*" 31 | kubernetes: 32 | patterns: 33 | - "k8s.io/*" 34 | - "sigs.k8s.io/*" 35 | openshift: 36 | patterns: 37 | - "github.com/openshift/*" 38 | -------------------------------------------------------------------------------- /pkg/cli/globalflags/logs.go: -------------------------------------------------------------------------------- 1 | package globalflags 2 | 3 | import ( 4 | "strings" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | type levelFlag log.Level 11 | 12 | var ( 13 | // some defaults for configuration 14 | defaultLogLevel = log.WarnLevel.String() 15 | logLevel levelFlag 16 | ) 17 | 18 | // String returns log level string 19 | func (l *levelFlag) String() string { 20 | return log.Level(*l).String() 21 | } 22 | 23 | // Set updates the log level 24 | func (l *levelFlag) Set(value string) error { 25 | lvl, err := log.ParseLevel(strings.TrimSpace(value)) 26 | if err == nil { 27 | *l = levelFlag(lvl) 28 | } 29 | log.SetLevel(lvl) 30 | return err 31 | } 32 | 33 | // Type defines log level type 34 | func (l *levelFlag) Type() string { 35 | return "string" 36 | } 37 | 38 | // AddVerbosityFlag add Persistent verbosity flag 39 | func AddVerbosityFlag(cmd *cobra.Command) { 40 | // Set default log level 41 | _ = logLevel.Set(defaultLogLevel) 42 | logLevelFlag := cmd.PersistentFlags().VarPF( 43 | &logLevel, 44 | "verbosity", 45 | "v", 46 | "Verbosity level: panic, fatal, error, warn, info, debug", 47 | ) 48 | logLevelFlag.NoOptDefVal = log.InfoLevel.String() 49 | } 50 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 10 | 11 | ### What type of PR is this? 12 | 13 | - [ ] fix (Bug Fix) 14 | - [ ] feat (New Feature) 15 | - [ ] docs (Documentation) 16 | - [ ] test (Test Coverage) 17 | - [ ] chore (Clean Up / Maintenance Tasks) 18 | - [ ] other (Anything that doesn't fit the above) 19 | 20 | ### What this PR does / Why we need it? 21 | 22 | ### Which Jira/Github issue(s) does this PR fix? 23 | 24 | - Related Issue # 25 | - Closes # 26 | 27 | ### Special notes for your reviewer 28 | 29 | ### Unit Test Coverage 30 | #### Guidelines 31 | - If it's a new sub-command or new function to an existing sub-command, please cover at least 50% of the code 32 | - If it's a bug fix for an existing sub-command, please cover 70% of the code 33 | 34 | #### Test coverage checks 35 | - [ ] Added unit tests 36 | - [ ] Created jira card to add unit test 37 | - [ ] This PR may not need unit tests 38 | 39 | ### Pre-checks (if applicable) 40 | - [ ] Ran unit tests locally 41 | - [ ] Validated the changes in a cluster 42 | - [ ] Included documentation changes with PR 43 | - [ ] Backward compatible 44 | 45 | 46 | /label tide/merge-method-squash 47 | -------------------------------------------------------------------------------- /pkg/ai/mcp/backplane_login.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/modelcontextprotocol/go-sdk/mcp" 9 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/login" 10 | ) 11 | 12 | type BackplaneLoginArgs struct { 13 | ClusterID string `json:"clusterId" jsonschema:"description:the cluster ID for backplane login"` 14 | } 15 | 16 | func BackplaneLogin(ctx context.Context, request *mcp.CallToolRequest, input BackplaneLoginArgs) (*mcp.CallToolResult, any, error) { 17 | clusterID := strings.TrimSpace(input.ClusterID) 18 | if clusterID == "" { 19 | return &mcp.CallToolResult{ 20 | Content: []mcp.Content{ 21 | &mcp.TextContent{Text: "Error: Cluster ID is required for backplane login"}, 22 | }, 23 | }, nil, fmt.Errorf("cluster ID cannot be empty") 24 | } 25 | 26 | // Call the runLogin function directly instead of using exec 27 | err := login.LoginCmd.RunE(login.LoginCmd, []string{clusterID}) 28 | 29 | if err != nil { 30 | errorMessage := fmt.Sprintf("Failed to login to cluster '%s'. Error: %v", clusterID, err) 31 | 32 | return &mcp.CallToolResult{ 33 | Content: []mcp.Content{ 34 | &mcp.TextContent{Text: errorMessage}, 35 | }, 36 | }, nil, nil // Return nil error since we're handling it gracefully 37 | } 38 | 39 | // Success case 40 | successMessage := fmt.Sprintf("Successfully logged in to cluster '%s'", clusterID) 41 | 42 | return &mcp.CallToolResult{ 43 | Content: []mcp.Content{ 44 | &mcp.TextContent{Text: successMessage}, 45 | }, 46 | }, nil, nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/utils/shell_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("ShellChecker", func() { 9 | var ( 10 | shellChecker ShellCheckerInterface 11 | ) 12 | 13 | BeforeEach(func() { 14 | shellChecker = DefaultShellChecker{} 15 | }) 16 | 17 | Context("When checking a valid shell", func() { 18 | It("Should return true for a valid shell path", func() { 19 | validShellPath := "/bin/bash" 20 | Expect(shellChecker.IsValidShell(validShellPath)).To(BeTrue()) 21 | }) 22 | 23 | It("Should return true for another valid shell path", func() { 24 | validShellPath := "/bin/zsh" 25 | Expect(shellChecker.IsValidShell(validShellPath)).To(BeTrue()) 26 | }) 27 | }) 28 | 29 | Context("When checking an invalid shell", func() { 30 | It("Should return false for an invalid shell path", func() { 31 | invalidShellPath := "/invalid/shell/path" 32 | Expect(shellChecker.IsValidShell(invalidShellPath)).To(BeFalse()) 33 | }) 34 | 35 | It("Should return false for another invalid shell path", func() { 36 | invalidShellPath := "/another/invalid/shell/path" 37 | Expect(shellChecker.IsValidShell(invalidShellPath)).To(BeFalse()) 38 | }) 39 | }) 40 | 41 | Context("When checking an empty shell path", func() { 42 | It("Should return false", func() { 43 | Expect(shellChecker.IsValidShell("")).To(BeFalse()) 44 | }) 45 | }) 46 | 47 | Context("When checking a non-existent shell path", func() { 48 | It("Should return false", func() { 49 | nonExistentShellPath := "/path/that/does/not/exist" 50 | Expect(shellChecker.IsValidShell(nonExistentShellPath)).To(BeFalse()) 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/accessrequest/getAccessRequest.go: -------------------------------------------------------------------------------- 1 | package accessrequest 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/openshift/backplane-cli/pkg/accessrequest" 7 | "github.com/openshift/backplane-cli/pkg/ocm" 8 | 9 | logger "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // newGetAccessRequestCmd returns cobra command 14 | func newGetAccessRequestCmd() *cobra.Command { 15 | cmd := &cobra.Command{ 16 | Use: "get", 17 | Short: "Get the active (pending or approved) access request", 18 | Args: cobra.ExactArgs(0), 19 | SilenceUsage: true, 20 | SilenceErrors: true, 21 | RunE: runGetAccessRequest, 22 | } 23 | 24 | return cmd 25 | } 26 | 27 | // runGetAccessRequest retrieves the active access request and print it 28 | func runGetAccessRequest(cmd *cobra.Command, args []string) error { 29 | clusterID, err := accessrequest.GetClusterID(cmd) 30 | if err != nil { 31 | return fmt.Errorf("failed to compute cluster ID: %v", err) 32 | } 33 | 34 | ocmConnection, err := ocm.DefaultOCMInterface.SetupOCMConnection() 35 | if err != nil { 36 | return fmt.Errorf("failed to create OCM connection: %v", err) 37 | } 38 | 39 | accessRequest, err := accessrequest.GetAccessRequest(ocmConnection, clusterID) 40 | 41 | if err != nil { 42 | return err 43 | } 44 | 45 | if accessRequest == nil { 46 | logger.Warnf("no pending or approved access request for cluster '%s'", clusterID) 47 | fmt.Printf("To get denied or expired access requests, run: ocm get /api/access_transparency/v1/access_requests -p search=\"cluster_id='%s'\"\n", clusterID) 48 | } else { 49 | accessrequest.PrintAccessRequest(clusterID, accessRequest) 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/accessrequest/expireAccessRequest.go: -------------------------------------------------------------------------------- 1 | package accessrequest 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/openshift/backplane-cli/pkg/accessrequest" 7 | "github.com/openshift/backplane-cli/pkg/ocm" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // newExpireAccessRequestCmd returns cobra command 13 | func newExpireAccessRequestCmd() *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "expire", 16 | Short: "Expire the active (pending or approved) access request", 17 | Args: cobra.ExactArgs(0), 18 | SilenceUsage: true, 19 | SilenceErrors: true, 20 | RunE: runExpireAccessRequest, 21 | } 22 | 23 | return cmd 24 | } 25 | 26 | // runExpireAccessRequest retrieves the active access request and expire it 27 | func runExpireAccessRequest(cmd *cobra.Command, args []string) error { 28 | clusterID, err := accessrequest.GetClusterID(cmd) 29 | if err != nil { 30 | return fmt.Errorf("failed to compute cluster ID: %v", err) 31 | } 32 | 33 | ocmConnection, err := ocm.DefaultOCMInterface.SetupOCMConnection() 34 | if err != nil { 35 | return fmt.Errorf("failed to create OCM connection: %v", err) 36 | } 37 | 38 | accessRequest, err := accessrequest.GetAccessRequest(ocmConnection, clusterID) 39 | 40 | if err != nil { 41 | return fmt.Errorf("failed to retrieve access request: %v", err) 42 | } 43 | 44 | if accessRequest == nil { 45 | return fmt.Errorf("no pending or approved access request for cluster '%s'", clusterID) 46 | } 47 | 48 | err = accessrequest.ExpireAccessRequest(ocmConnection, accessRequest) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | fmt.Printf("Access request '%s' has been expired\n", accessRequest.HREF()) 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/info/info_test.go: -------------------------------------------------------------------------------- 1 | package info_test 2 | 3 | import ( 4 | "runtime/debug" 5 | 6 | "go.uber.org/mock/gomock" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "github.com/openshift/backplane-cli/pkg/info" 10 | infoMock "github.com/openshift/backplane-cli/pkg/info/mocks" 11 | ) 12 | 13 | var _ = Describe("Info", func() { 14 | var ( 15 | mockCtrl *gomock.Controller 16 | mockBuildInfoService *infoMock.MockBuildInfoService 17 | ) 18 | 19 | BeforeEach(func() { 20 | mockCtrl = gomock.NewController(GinkgoT()) 21 | mockBuildInfoService = infoMock.NewMockBuildInfoService(mockCtrl) 22 | info.DefaultBuildInfoService = mockBuildInfoService 23 | }) 24 | 25 | AfterEach(func() { 26 | mockCtrl.Finish() 27 | }) 28 | 29 | Context("When getting build version", func() { 30 | It("Should return the pre-set Version is available", func() { 31 | info.Version = "whatever" 32 | 33 | version := info.DefaultInfoService.GetVersion() 34 | Expect(version).To(Equal("whatever")) 35 | }) 36 | It("Should return a version when go bulid info is available and there is no pre-set Version", func() { 37 | info.Version = "" 38 | mockBuildInfoService.EXPECT().GetBuildInfo().Return(&debug.BuildInfo{ 39 | Main: debug.Module{ 40 | Version: "v2.23.4", 41 | }, 42 | }, true).Times(1) 43 | 44 | version := info.DefaultInfoService.GetVersion() 45 | Expect(version).To(Equal("2.23.4")) 46 | }) 47 | It("Should return an unknown when no way to determine version", func() { 48 | info.Version = "" 49 | mockBuildInfoService.EXPECT().GetBuildInfo().Return(nil, false).Times(1) 50 | version := info.DefaultInfoService.GetVersion() 51 | Expect(version).To(Equal("unknown")) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/script/script.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Red Hat, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package script 17 | 18 | import ( 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | func NewScriptCmd() *cobra.Command { 23 | cmd := &cobra.Command{ 24 | Use: "script", 25 | Aliases: []string{"scripts"}, 26 | Short: "Backplane script resource to run pre-defined scripts", 27 | SilenceUsage: true, 28 | } 29 | 30 | // url flag 31 | // Denotes backplane url 32 | // If this flag is empty, its value will be populated by --cluster-id flag supplied by user. cluster-id flag will be used to find corresponding hive-shard and composing backplane url. 33 | cmd.PersistentFlags().String( 34 | "url", 35 | "", 36 | "Specify backplane url. Default: The corresponding hive shard of the target cluster.", 37 | ) 38 | 39 | // cluster-id Flag 40 | cmd.PersistentFlags().StringP( 41 | "cluster-id", 42 | "c", 43 | "", 44 | "Cluster ID could be cluster name, id or external-id") 45 | 46 | // raw Flag 47 | cmd.PersistentFlags().Bool("raw", false, "Prints the raw response returned by the backplane API") 48 | 49 | cmd.AddCommand(newListScriptCmd()) 50 | cmd.AddCommand(newDescribeScriptCmd()) 51 | return cmd 52 | } 53 | -------------------------------------------------------------------------------- /pkg/info/mocks/infoMock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/openshift/backplane-cli/pkg/info (interfaces: InfoService) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=./pkg/info/mocks/infoMock.go -package=mocks github.com/openshift/backplane-cli/pkg/info InfoService 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | gomock "go.uber.org/mock/gomock" 16 | ) 17 | 18 | // MockInfoService is a mock of InfoService interface. 19 | type MockInfoService struct { 20 | ctrl *gomock.Controller 21 | recorder *MockInfoServiceMockRecorder 22 | isgomock struct{} 23 | } 24 | 25 | // MockInfoServiceMockRecorder is the mock recorder for MockInfoService. 26 | type MockInfoServiceMockRecorder struct { 27 | mock *MockInfoService 28 | } 29 | 30 | // NewMockInfoService creates a new mock instance. 31 | func NewMockInfoService(ctrl *gomock.Controller) *MockInfoService { 32 | mock := &MockInfoService{ctrl: ctrl} 33 | mock.recorder = &MockInfoServiceMockRecorder{mock} 34 | return mock 35 | } 36 | 37 | // EXPECT returns an object that allows the caller to indicate expected use. 38 | func (m *MockInfoService) EXPECT() *MockInfoServiceMockRecorder { 39 | return m.recorder 40 | } 41 | 42 | // GetVersion mocks base method. 43 | func (m *MockInfoService) GetVersion() string { 44 | m.ctrl.T.Helper() 45 | ret := m.ctrl.Call(m, "GetVersion") 46 | ret0, _ := ret[0].(string) 47 | return ret0 48 | } 49 | 50 | // GetVersion indicates an expected call of GetVersion. 51 | func (mr *MockInfoServiceMockRecorder) GetVersion() *gomock.Call { 52 | mr.mock.ctrl.T.Helper() 53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVersion", reflect.TypeOf((*MockInfoService)(nil).GetVersion)) 54 | } 55 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/config/get.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/openshift/backplane-cli/pkg/cli/config" 9 | ) 10 | 11 | func newGetCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "get", 14 | Short: "Get Backplane CLI configuration variables", 15 | Example: "ocm backplane config get url", 16 | SilenceUsage: true, 17 | Args: cobra.ExactArgs(1), 18 | RunE: getConfig, 19 | } 20 | return cmd 21 | } 22 | 23 | func getConfig(cmd *cobra.Command, args []string) error { 24 | config, err := config.GetBackplaneConfiguration() 25 | if err != nil { 26 | return err 27 | } 28 | 29 | proxyURL := "" 30 | if config.ProxyURL != nil { 31 | proxyURL = *config.ProxyURL 32 | } 33 | 34 | switch args[0] { 35 | case URLConfigVar: 36 | fmt.Printf("%s: %s\n", URLConfigVar, config.URL) 37 | case ProxyURLConfigVar: 38 | fmt.Printf("%s: %s\n", ProxyURLConfigVar, proxyURL) 39 | case SessionConfigVar: 40 | fmt.Printf("%s: %s\n", SessionConfigVar, config.SessionDirectory) 41 | case PagerDutyAPIConfigVar: 42 | fmt.Printf("%s: %s\n", PagerDutyAPIConfigVar, config.PagerDutyAPIKey) 43 | case GovcloudVar: 44 | fmt.Printf("%s: %t\n", GovcloudVar, config.Govcloud) 45 | case "all": 46 | fmt.Printf("%s: %s\n", URLConfigVar, config.URL) 47 | fmt.Printf("%s: %s\n", ProxyURLConfigVar, proxyURL) 48 | fmt.Printf("%s: %s\n", SessionConfigVar, config.SessionDirectory) 49 | fmt.Printf("%s: %s\n", PagerDutyAPIConfigVar, config.PagerDutyAPIKey) 50 | fmt.Printf("%s: %t\n", GovcloudVar, config.Govcloud) 51 | default: 52 | return fmt.Errorf("supported config variables are %s, %s, %s, %s, & %s", URLConfigVar, ProxyURLConfigVar, SessionConfigVar, PagerDutyAPIConfigVar, GovcloudVar) 53 | } 54 | 55 | return nil 56 | } -------------------------------------------------------------------------------- /ci-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Manual release steps for reference: 3 | # https://github.com/openshift/backplane-cli/blob/main/docs/release.md 4 | 5 | set -e 6 | 7 | # Ensure GITHUB_TOKEN is set 8 | if [ -z "$GITHUB_TOKEN" ]; then 9 | echo "Error: GITHUB_TOKEN is not set in ci-release.sh." 10 | exit 1 11 | else 12 | echo "GITHUB_TOKEN is set in ci-release.sh" 13 | fi 14 | 15 | # Define repository URL with token 16 | REPO_URL="https://${GITHUB_TOKEN}@github.com/openshift/backplane-cli.git" 17 | 18 | # Extract version from VERSION.md 19 | VERSION=$(grep 'Version:' VERSION.md | awk '{print $2}') 20 | 21 | # Check if version is extracted correctly 22 | if [ -z "$VERSION" ]; then 23 | echo "Error: Failed to extract version from VERSION.md" 24 | exit 1 25 | fi 26 | 27 | # Check if the tag already exists in the repository 28 | if git rev-parse "v$VERSION" >/dev/null 2>&1; then 29 | echo "Error: Tag v$VERSION already exists. Aborting release." 30 | exit 1 31 | fi 32 | 33 | # Git configurations 34 | git config user.name "CI release" 35 | git config user.email "ci-test@release.com" 36 | 37 | # Ensure the remote repository 'upstream' is set to the correct URL 38 | if git remote | grep -iq 'upstream'; then 39 | current_url=$(git remote get-url upstream) 40 | if [ "$current_url" != "$REPO_URL" ]; then 41 | git remote set-url upstream $REPO_URL 42 | fi 43 | else 44 | git remote add upstream $REPO_URL 45 | fi 46 | 47 | # Ensure working on the latest main 48 | git fetch upstream 49 | git checkout upstream/main 50 | 51 | # Tagging the release 52 | git tag -a "v${VERSION}" -m "Release v${VERSION}" 53 | 54 | # Print the remote URL again before pushing 55 | echo "Final upstream URL before push:" 56 | git remote -v 57 | 58 | # Push the tag to the remote repository 59 | git push upstream "v${VERSION}" 60 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/elevate/elevate.go: -------------------------------------------------------------------------------- 1 | package elevate 2 | 3 | import ( 4 | "github.com/openshift/backplane-cli/pkg/elevate" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var noReason bool 9 | var ElevateCmd = &cobra.Command{ 10 | Use: "elevate [ []]", 11 | Short: "Give a justification for elevating privileges to backplane-cluster-admin and attach it to your user object", 12 | Long: `Elevate to backplane-cluster-admin, and give a reason to do so. 13 | This will then be forwarded to your audit collection backend of your choice as the 'Impersonate-User-Extra' HTTP header, which can then be used for tracking, compliance, and security reasons. 14 | The command creates a temporary kubeconfig and clusterrole for your user, to allow you to add the extra header to your Kube API request. 15 | The provided reason will be store for 20 minutes in order to be used by future elevate commands if the next provided reason is empty. 16 | If the provided reason is empty and no elevation with reason has been done in the last 20 min, and if also the stdin and stderr are not redirection, 17 | then a prompt will be done to enter a none empty reason that will be also stored for future elevation. 18 | If no COMMAND (and eventualy also REASON) is/are provided then the command will just be used to initialize elevate context for future elevate command.`, 19 | Example: "ocm backplane elevate -- get po -A", 20 | RunE: runElevate, 21 | SilenceUsage: true, 22 | } 23 | 24 | func init() { 25 | ElevateCmd.Flags().BoolVarP( 26 | &noReason, 27 | "no-reason", 28 | "n", 29 | false, 30 | "Do not take reason as first argument, and prompt for it if needed and possible.", 31 | ) 32 | } 33 | 34 | func runElevate(cmd *cobra.Command, argv []string) error { 35 | if noReason { 36 | argv = append([]string{""}, argv...) 37 | } 38 | return elevate.RunElevate(argv) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/healthcheck/mocks/httpClientMock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/openshift/backplane-cli/pkg/healthcheck (interfaces: HTTPClient) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=./pkg/healthcheck/mocks/httpClientMock.go -package=mocks github.com/openshift/backplane-cli/pkg/healthcheck HTTPClient 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | http "net/http" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockHTTPClient is a mock of HTTPClient interface. 20 | type MockHTTPClient struct { 21 | ctrl *gomock.Controller 22 | recorder *MockHTTPClientMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient. 27 | type MockHTTPClientMockRecorder struct { 28 | mock *MockHTTPClient 29 | } 30 | 31 | // NewMockHTTPClient creates a new mock instance. 32 | func NewMockHTTPClient(ctrl *gomock.Controller) *MockHTTPClient { 33 | mock := &MockHTTPClient{ctrl: ctrl} 34 | mock.recorder = &MockHTTPClientMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // Get mocks base method. 44 | func (m *MockHTTPClient) Get(url string) (*http.Response, error) { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "Get", url) 47 | ret0, _ := ret[0].(*http.Response) 48 | ret1, _ := ret[1].(error) 49 | return ret0, ret1 50 | } 51 | 52 | // Get indicates an expected call of Get. 53 | func (mr *MockHTTPClientMockRecorder) Get(url any) *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockHTTPClient)(nil).Get), url) 56 | } 57 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/status/status.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Red Hat, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package status 18 | 19 | import ( 20 | "fmt" 21 | 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/openshift/backplane-cli/pkg/ocm" 25 | "github.com/openshift/backplane-cli/pkg/utils" 26 | ) 27 | 28 | var StatusCmd = &cobra.Command{ 29 | Use: "status", 30 | Short: "Show the current backplane login info", 31 | Long: `It will read the effecitve cluster id from the kubeconfig, 32 | and print the essential info. 33 | `, 34 | Args: cobra.ExactArgs(0), 35 | RunE: runStatus, 36 | SilenceUsage: true, 37 | } 38 | 39 | func runStatus(cmd *cobra.Command, argv []string) error { 40 | 41 | clusterInfo, err := utils.DefaultClusterUtils.GetBackplaneClusterFromConfig() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | clusterV1, err := ocm.DefaultOCMInterface.GetClusterInfoByID(clusterInfo.ClusterID) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | clusterName := clusterV1.Name() 52 | basedomain := clusterV1.DNS().BaseDomain() 53 | 54 | fmt.Printf( 55 | "Cluster ID: %s\n"+ 56 | "Cluster Name: %s\n"+ 57 | "Cluster Basedomain: %s\n"+ 58 | "Backplane Server: %s\n", 59 | clusterInfo.ClusterID, 60 | clusterName, 61 | basedomain, 62 | clusterInfo.BackplaneHost, 63 | ) 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/utils/jwt.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/golang-jwt/jwt/v4" 8 | ) 9 | 10 | // GetStringFieldFromJWT extracts a string field from a JWT token without verification. 11 | // It parses the token and returns the specified field value as a string. 12 | // Returns an error if the token cannot be parsed, the field doesn't exist, or the field is not a string. 13 | func GetStringFieldFromJWT(token string, field string) (string, error) { 14 | var jwtToken *jwt.Token 15 | var err error 16 | 17 | parser := new(jwt.Parser) 18 | jwtToken, _, err = parser.ParseUnverified(token, jwt.MapClaims{}) 19 | if err != nil { 20 | return "", fmt.Errorf("failed to parse jwt") 21 | } 22 | 23 | claims, ok := jwtToken.Claims.(jwt.MapClaims) 24 | if !ok { 25 | return "", err 26 | } 27 | 28 | claim, ok := claims[field] 29 | if !ok { 30 | return "", fmt.Errorf("no field %v on given token", field) 31 | } 32 | 33 | claimString, ok := claim.(string) 34 | if !ok { 35 | return "", fmt.Errorf("field %v does not contain a string value", field) 36 | } 37 | 38 | return claimString, nil 39 | } 40 | 41 | // GetUsernameFromJWT returns the username extracted from JWT token 42 | func GetUsernameFromJWT(token string) string { 43 | var jwtToken *jwt.Token 44 | var err error 45 | parser := new(jwt.Parser) 46 | jwtToken, _, err = parser.ParseUnverified(token, jwt.MapClaims{}) 47 | if err != nil { 48 | return "anonymous" 49 | } 50 | claims, ok := jwtToken.Claims.(jwt.MapClaims) 51 | if !ok { 52 | return "anonymous" 53 | } 54 | claim, ok := claims["username"] 55 | if !ok { 56 | return "anonymous" 57 | } 58 | return claim.(string) 59 | } 60 | 61 | // GetContextNickname returns a nickname of a context 62 | func GetContextNickname(namespace, clusterNick, userNick string) string { 63 | tokens := strings.SplitN(userNick, "/", 2) 64 | return namespace + "/" + clusterNick + "/" + tokens[0] 65 | } 66 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/openshift/backplane-cli/pkg/cli/globalflags" 11 | "github.com/openshift/backplane-cli/pkg/cli/session" 12 | "github.com/openshift/backplane-cli/pkg/info" 13 | ) 14 | 15 | var globalOpts = &globalflags.GlobalOptions{} 16 | 17 | func NewCmdSession() *cobra.Command { 18 | options := session.Options{} 19 | 20 | session := session.BackplaneSession{ 21 | Options: &options, 22 | } 23 | sessionCmd := &cobra.Command{ 24 | Use: "session [flags] [session-alias]", 25 | Short: "Create an isolated environment to interact with a cluster in its own directory", 26 | Args: cobra.MaximumNArgs(1), 27 | DisableAutoGenTag: true, 28 | RunE: session.RunCommand, 29 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 30 | validEnvs := []string{} 31 | files, err := os.ReadDir(filepath.Join(os.Getenv("HOME"), info.BackplaneDefaultSessionDirectory)) 32 | if err != nil { 33 | return validEnvs, cobra.ShellCompDirectiveNoFileComp 34 | } 35 | for _, f := range files { 36 | if f.IsDir() && strings.HasPrefix(f.Name(), toComplete) { 37 | validEnvs = append(validEnvs, f.Name()) 38 | } 39 | } 40 | 41 | return validEnvs, cobra.ShellCompDirectiveNoFileComp 42 | }, 43 | } 44 | 45 | // Initialize global flags 46 | globalflags.AddGlobalFlags(sessionCmd, globalOpts) 47 | options.GlobalOpts = globalOpts 48 | 49 | // 50 | sessionCmd.Flags().BoolVarP( 51 | &options.DeleteSession, 52 | "delete", 53 | "d", 54 | false, 55 | "Delete session", 56 | ) 57 | 58 | sessionCmd.Flags().StringVarP( 59 | &options.ClusterID, 60 | "cluster-id", 61 | "c", 62 | "", 63 | "The cluster to create the session for", 64 | ) 65 | 66 | return sessionCmd 67 | } 68 | -------------------------------------------------------------------------------- /pkg/pagerduty/client.go: -------------------------------------------------------------------------------- 1 | package pagerduty 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | pdApi "github.com/PagerDuty/go-pagerduty" 8 | ) 9 | 10 | // PagerDutyClient is an interface for the actual PD API 11 | type PagerDutyClient interface { 12 | Connect(authToken string, options ...pdApi.ClientOptions) error 13 | ListIncidents(pdApi.ListIncidentsOptions) (*pdApi.ListIncidentsResponse, error) 14 | ListIncidentAlerts(incidentID string) (*pdApi.ListAlertsResponse, error) 15 | GetServiceWithContext(ctx context.Context, serviceID string, opts *pdApi.GetServiceOptions) (*pdApi.Service, error) 16 | } 17 | 18 | type DefaultPagerDutyClientImpl struct { 19 | client *pdApi.Client 20 | } 21 | 22 | // NewClient creates an instance of PDClient that is then used to connect to the actual pagerduty client. 23 | func NewClient() *DefaultPagerDutyClientImpl { 24 | return &DefaultPagerDutyClientImpl{} 25 | } 26 | 27 | // Connect uses the information stored in new client to create a new PagerDuty connection. 28 | // It returns the PDClient object with pagerduty API connection initialized. 29 | func (c *DefaultPagerDutyClientImpl) Connect(authToken string, options ...pdApi.ClientOptions) error { 30 | 31 | if authToken == "" { 32 | return fmt.Errorf("empty pagerduty token") 33 | } 34 | 35 | // Create a new PagerDuty API client 36 | c.client = pdApi.NewClient(authToken, options...) 37 | 38 | return nil 39 | } 40 | 41 | func (c *DefaultPagerDutyClientImpl) ListIncidents(opts pdApi.ListIncidentsOptions) (*pdApi.ListIncidentsResponse, error) { 42 | return c.client.ListIncidentsWithContext(context.TODO(), opts) 43 | } 44 | 45 | func (c *DefaultPagerDutyClientImpl) ListIncidentAlerts(incidentID string) (*pdApi.ListAlertsResponse, error) { 46 | return c.client.ListIncidentAlerts(incidentID) 47 | } 48 | 49 | func (c *DefaultPagerDutyClientImpl) GetServiceWithContext(ctx context.Context, serviceID string, opts *pdApi.GetServiceOptions) (*pdApi.Service, error) { 50 | return c.client.GetServiceWithContext(ctx, serviceID, opts) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/healthcheck/mocks/networkMock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/openshift/backplane-cli/pkg/healthcheck (interfaces: NetworkInterface) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=./pkg/healthcheck/mocks/networkMock.go -package=mocks github.com/openshift/backplane-cli/pkg/healthcheck NetworkInterface 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | net "net" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockNetworkInterface is a mock of NetworkInterface interface. 20 | type MockNetworkInterface struct { 21 | ctrl *gomock.Controller 22 | recorder *MockNetworkInterfaceMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockNetworkInterfaceMockRecorder is the mock recorder for MockNetworkInterface. 27 | type MockNetworkInterfaceMockRecorder struct { 28 | mock *MockNetworkInterface 29 | } 30 | 31 | // NewMockNetworkInterface creates a new mock instance. 32 | func NewMockNetworkInterface(ctrl *gomock.Controller) *MockNetworkInterface { 33 | mock := &MockNetworkInterface{ctrl: ctrl} 34 | mock.recorder = &MockNetworkInterfaceMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockNetworkInterface) EXPECT() *MockNetworkInterfaceMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // Interfaces mocks base method. 44 | func (m *MockNetworkInterface) Interfaces() ([]net.Interface, error) { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "Interfaces") 47 | ret0, _ := ret[0].([]net.Interface) 48 | ret1, _ := ret[1].(error) 49 | return ret0, ret1 50 | } 51 | 52 | // Interfaces indicates an expected call of Interfaces. 53 | func (mr *MockNetworkInterfaceMockRecorder) Interfaces() *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Interfaces", reflect.TypeOf((*MockNetworkInterface)(nil).Interfaces)) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/info/mocks/buildInfoMock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/openshift/backplane-cli/pkg/info (interfaces: BuildInfoService) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=./pkg/info/mocks/buildInfoMock.go -package=mocks github.com/openshift/backplane-cli/pkg/info BuildInfoService 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | reflect "reflect" 14 | debug "runtime/debug" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockBuildInfoService is a mock of BuildInfoService interface. 20 | type MockBuildInfoService struct { 21 | ctrl *gomock.Controller 22 | recorder *MockBuildInfoServiceMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockBuildInfoServiceMockRecorder is the mock recorder for MockBuildInfoService. 27 | type MockBuildInfoServiceMockRecorder struct { 28 | mock *MockBuildInfoService 29 | } 30 | 31 | // NewMockBuildInfoService creates a new mock instance. 32 | func NewMockBuildInfoService(ctrl *gomock.Controller) *MockBuildInfoService { 33 | mock := &MockBuildInfoService{ctrl: ctrl} 34 | mock.recorder = &MockBuildInfoServiceMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockBuildInfoService) EXPECT() *MockBuildInfoServiceMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // GetBuildInfo mocks base method. 44 | func (m *MockBuildInfoService) GetBuildInfo() (*debug.BuildInfo, bool) { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "GetBuildInfo") 47 | ret0, _ := ret[0].(*debug.BuildInfo) 48 | ret1, _ := ret[1].(bool) 49 | return ret0, ret1 50 | } 51 | 52 | // GetBuildInfo indicates an expected call of GetBuildInfo. 53 | func (mr *MockBuildInfoServiceMockRecorder) GetBuildInfo() *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBuildInfo", reflect.TypeOf((*MockBuildInfoService)(nil).GetBuildInfo)) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/utils/mocks/shellCheckerMock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/openshift/backplane-cli/pkg/utils (interfaces: ShellCheckerInterface) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=./pkg/utils/mocks/shellCheckerMock.go -package=mocks github.com/openshift/backplane-cli/pkg/utils ShellCheckerInterface 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | gomock "go.uber.org/mock/gomock" 16 | ) 17 | 18 | // MockShellCheckerInterface is a mock of ShellCheckerInterface interface. 19 | type MockShellCheckerInterface struct { 20 | ctrl *gomock.Controller 21 | recorder *MockShellCheckerInterfaceMockRecorder 22 | isgomock struct{} 23 | } 24 | 25 | // MockShellCheckerInterfaceMockRecorder is the mock recorder for MockShellCheckerInterface. 26 | type MockShellCheckerInterfaceMockRecorder struct { 27 | mock *MockShellCheckerInterface 28 | } 29 | 30 | // NewMockShellCheckerInterface creates a new mock instance. 31 | func NewMockShellCheckerInterface(ctrl *gomock.Controller) *MockShellCheckerInterface { 32 | mock := &MockShellCheckerInterface{ctrl: ctrl} 33 | mock.recorder = &MockShellCheckerInterfaceMockRecorder{mock} 34 | return mock 35 | } 36 | 37 | // EXPECT returns an object that allows the caller to indicate expected use. 38 | func (m *MockShellCheckerInterface) EXPECT() *MockShellCheckerInterfaceMockRecorder { 39 | return m.recorder 40 | } 41 | 42 | // IsValidShell mocks base method. 43 | func (m *MockShellCheckerInterface) IsValidShell(shellPath string) bool { 44 | m.ctrl.T.Helper() 45 | ret := m.ctrl.Call(m, "IsValidShell", shellPath) 46 | ret0, _ := ret[0].(bool) 47 | return ret0 48 | } 49 | 50 | // IsValidShell indicates an expected call of IsValidShell. 51 | func (mr *MockShellCheckerInterfaceMockRecorder) IsValidShell(shellPath any) *gomock.Call { 52 | mr.mock.ctrl.T.Helper() 53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsValidShell", reflect.TypeOf((*MockShellCheckerInterface)(nil).IsValidShell), shellPath) 54 | } 55 | -------------------------------------------------------------------------------- /internal/upgrade/writer.go: -------------------------------------------------------------------------------- 1 | package upgrade 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func NewSafeWriter(opts ...SafeWriterOption) *SafeWriter { 13 | var cfg SafeWriterConfig 14 | 15 | cfg.Option(opts...) 16 | cfg.Default() 17 | 18 | return &SafeWriter{ 19 | cfg: cfg, 20 | } 21 | } 22 | 23 | type SafeWriter struct { 24 | cfg SafeWriterConfig 25 | } 26 | 27 | func (w *SafeWriter) Write(path string, data []byte) error { 28 | backup, err := w.backup(path) 29 | if err != nil { 30 | return fmt.Errorf("backing up path: %w", err) 31 | } 32 | 33 | const perms = os.FileMode(0o755) 34 | 35 | if err := os.WriteFile(path, data, perms); err != nil { 36 | if backup != "" { 37 | if err := os.Rename(backup, path); err != nil { 38 | w.cfg.Log.Errorf("restoring from backup: %v", err) 39 | } 40 | } 41 | 42 | return fmt.Errorf("writing to path %q: %w", path, err) 43 | } 44 | 45 | if err := os.Remove(backup); err != nil { 46 | return fmt.Errorf("cleaning up old binary: %w", err) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | var ErrNotAFile = errors.New("not a file") 53 | 54 | func (w *SafeWriter) backup(path string) (string, error) { 55 | if stat, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { 56 | return "", nil 57 | } else if err != nil { 58 | return "", fmt.Errorf("inspecting file path: %w", err) 59 | } else if stat.IsDir() { 60 | return "", ErrNotAFile 61 | } 62 | 63 | backup := path + "_" + time.Now().Format("2006.01.02_15:04:05") 64 | 65 | if err := os.Rename(path, backup); err != nil { 66 | return "", fmt.Errorf("backing up file path: %w", err) 67 | } 68 | 69 | return backup, nil 70 | } 71 | 72 | type SafeWriterConfig struct { 73 | Log logrus.FieldLogger 74 | } 75 | 76 | func (c *SafeWriterConfig) Option(opts ...SafeWriterOption) { 77 | for _, opt := range opts { 78 | opt.ConfigureSafeWriter(c) 79 | } 80 | } 81 | 82 | func (c *SafeWriterConfig) Default() { 83 | if c.Log == nil { 84 | c.Log = logrus.New() 85 | } 86 | } 87 | 88 | type SafeWriterOption interface { 89 | ConfigureSafeWriter(*SafeWriterConfig) 90 | } 91 | -------------------------------------------------------------------------------- /pkg/healthcheck/check_vpn.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | logger "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // CheckVPNConnectivity checks the VPN connectivity 11 | func CheckVPNConnectivity(netInterfaces NetworkInterface, client HTTPClient) error { 12 | vpnInterfaces := []string{"tun", "tap", "ppp", "wg", "utun"} 13 | 14 | interfaces, err := netInterfaces.Interfaces() 15 | if err != nil { 16 | logger.Errorf("Failed to get network interfaces: %v", err) 17 | return fmt.Errorf("failed to get network interfaces: %v", err) 18 | } 19 | 20 | vpnConnected := false 21 | for _, iface := range interfaces { 22 | for _, vpnPrefix := range vpnInterfaces { 23 | if strings.HasPrefix(iface.Name, vpnPrefix) { 24 | vpnConnected = true 25 | break 26 | } 27 | } 28 | if vpnConnected { 29 | break 30 | } 31 | } 32 | 33 | if !vpnConnected { 34 | errMsg := fmt.Sprintf("No VPN interfaces found: %v", vpnInterfaces) 35 | logger.Warn(errMsg) 36 | return fmt.Errorf("%s", errMsg) 37 | } 38 | 39 | vpnCheckEndpoint, err := GetVPNCheckEndpointFunc() 40 | if err != nil { 41 | logger.Errorf("Failed to get VPN check endpoint: %v", err) 42 | return err 43 | } 44 | if err := testEndPointConnectivity(vpnCheckEndpoint, client); err != nil { 45 | errMsg := fmt.Sprintf("Failed to access internal URL %s: %v", vpnCheckEndpoint, err) 46 | logger.Errorf("%s", errMsg) 47 | return fmt.Errorf("%s", errMsg) 48 | } 49 | 50 | return nil 51 | } 52 | 53 | // GetVPNCheckEndpoint retrieves the VPN check endpoint from the backplane configuration. 54 | // Returns an error if the configuration cannot be loaded or the endpoint is not configured. 55 | func GetVPNCheckEndpoint() (string, error) { 56 | bpConfig, err := GetConfigFunc() 57 | if err != nil { 58 | logger.Errorf("Failed to get backplane configuration: %v", err) 59 | return "", fmt.Errorf("failed to get backplane configuration: %v", err) 60 | } 61 | if bpConfig.VPNCheckEndpoint == "" { 62 | errMsg := "VPN check endpoint not configured" 63 | logger.Warn(errMsg) 64 | return "", fmt.Errorf("%s", errMsg) 65 | } 66 | return bpConfig.VPNCheckEndpoint, nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/jira/ohssService.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/andygrunwald/go-jira" 8 | ) 9 | 10 | const ( 11 | JiraOHSSProjectKey = "OHSS" 12 | CustomFieldClusterID = "customfield_12316349" 13 | ) 14 | 15 | type OHSSIssue struct { 16 | ID string 17 | Key string 18 | Title string 19 | ProjectKey string 20 | WebURL string 21 | ClusterID string 22 | } 23 | 24 | type OHSSService struct { 25 | issueService IssueServiceInterface 26 | } 27 | 28 | func NewOHSSService(client IssueServiceInterface) *OHSSService { 29 | return &OHSSService{ 30 | issueService: client, 31 | } 32 | } 33 | 34 | // GetIssue returns matching issue from OHSS project 35 | func (j *OHSSService) GetIssue(issueID string) (ohssIssue OHSSIssue, err error) { 36 | 37 | if issueID == "" { 38 | return ohssIssue, fmt.Errorf("empty issue Id") 39 | } 40 | issue, _, err := j.issueService.Get(issueID, nil) 41 | if err != nil { 42 | return ohssIssue, err 43 | } 44 | if issue == nil { 45 | return ohssIssue, fmt.Errorf("no matching issue for issueID:%s", issueID) 46 | } 47 | 48 | if issue.Fields != nil { 49 | if issue.Fields.Project.Key != JiraOHSSProjectKey { 50 | return ohssIssue, fmt.Errorf("issue %s is not belongs to OHSS project", issueID) 51 | } 52 | } 53 | formatIssue, err := j.formatIssue(*issue) 54 | if err != nil { 55 | return ohssIssue, err 56 | } 57 | return formatIssue, nil 58 | 59 | } 60 | 61 | // formatIssue format the JIRA issue to OHSS Issue 62 | func (j *OHSSService) formatIssue(issue jira.Issue) (formatIssue OHSSIssue, err error) { 63 | 64 | formatIssue.ID = issue.ID 65 | formatIssue.Key = issue.Key 66 | if issue.Fields != nil { 67 | clusterID, clusterIDExist := issue.Fields.Unknowns[CustomFieldClusterID] 68 | if clusterIDExist { 69 | formatIssue.ClusterID = fmt.Sprintf("%s", clusterID) 70 | } 71 | formatIssue.ProjectKey = issue.Fields.Project.Key 72 | formatIssue.Title = issue.Fields.Summary 73 | } 74 | if issue.Self != "" { 75 | selfSlice := strings.SplitAfter(issue.Self, ".com") 76 | formatIssue.WebURL = fmt.Sprintf("%s/browse/%s", selfSlice[0], issue.Key) 77 | } 78 | 79 | return formatIssue, nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/ssm/mocks/mock_ssmclient.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/openshift/backplane-cli/cmd/ocm-backplane/cloud (interfaces: SSMClient) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=./pkg/ssm/mocks/mock_ssmclient.go -package=mocks github.com/openshift/backplane-cli/cmd/ocm-backplane/cloud SSMClient 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | ssm "github.com/aws/aws-sdk-go-v2/service/ssm" 17 | gomock "go.uber.org/mock/gomock" 18 | ) 19 | 20 | // MockSSMClient is a mock of SSMClient interface. 21 | type MockSSMClient struct { 22 | ctrl *gomock.Controller 23 | recorder *MockSSMClientMockRecorder 24 | isgomock struct{} 25 | } 26 | 27 | // MockSSMClientMockRecorder is the mock recorder for MockSSMClient. 28 | type MockSSMClientMockRecorder struct { 29 | mock *MockSSMClient 30 | } 31 | 32 | // NewMockSSMClient creates a new mock instance. 33 | func NewMockSSMClient(ctrl *gomock.Controller) *MockSSMClient { 34 | mock := &MockSSMClient{ctrl: ctrl} 35 | mock.recorder = &MockSSMClientMockRecorder{mock} 36 | return mock 37 | } 38 | 39 | // EXPECT returns an object that allows the caller to indicate expected use. 40 | func (m *MockSSMClient) EXPECT() *MockSSMClientMockRecorder { 41 | return m.recorder 42 | } 43 | 44 | // StartSession mocks base method. 45 | func (m *MockSSMClient) StartSession(ctx context.Context, params *ssm.StartSessionInput, optFns ...func(*ssm.Options)) (*ssm.StartSessionOutput, error) { 46 | m.ctrl.T.Helper() 47 | varargs := []any{ctx, params} 48 | for _, a := range optFns { 49 | varargs = append(varargs, a) 50 | } 51 | ret := m.ctrl.Call(m, "StartSession", varargs...) 52 | ret0, _ := ret[0].(*ssm.StartSessionOutput) 53 | ret1, _ := ret[1].(error) 54 | return ret0, ret1 55 | } 56 | 57 | // StartSession indicates an expected call of StartSession. 58 | func (mr *MockSSMClientMockRecorder) StartSession(ctx, params any, optFns ...any) *gomock.Call { 59 | mr.mock.ctrl.T.Helper() 60 | varargs := append([]any{ctx, params}, optFns...) 61 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartSession", reflect.TypeOf((*MockSSMClient)(nil).StartSession), varargs...) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/jira/ohssService_test.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "github.com/andygrunwald/go-jira" 5 | "go.uber.org/mock/gomock" 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | jiraMock "github.com/openshift/backplane-cli/pkg/jira/mocks" 9 | ) 10 | 11 | var _ = Describe("Jira", func() { 12 | var ( 13 | mockCtrl *gomock.Controller 14 | mockIssueService *jiraMock.MockIssueServiceInterface 15 | ohssService *OHSSService 16 | testOHSSID string 17 | testIssue jira.Issue 18 | issueFields *jira.IssueFields 19 | ) 20 | 21 | BeforeEach(func() { 22 | mockCtrl = gomock.NewController(GinkgoT()) 23 | mockIssueService = jiraMock.NewMockIssueServiceInterface(mockCtrl) 24 | ohssService = NewOHSSService(mockIssueService) 25 | testOHSSID = "OHSS-1000" 26 | issueFields = &jira.IssueFields{Project: jira.Project{Key: JiraOHSSProjectKey}} 27 | testIssue = jira.Issue{ID: testOHSSID, Fields: issueFields} 28 | 29 | }) 30 | 31 | AfterEach(func() { 32 | mockCtrl.Finish() 33 | }) 34 | 35 | Context("When Jira client executes", func() { 36 | It("Should return one issue", func() { 37 | 38 | mockIssueService.EXPECT().Get(testOHSSID, nil).Return(&testIssue, nil, nil).Times(1) 39 | 40 | issue, err := ohssService.GetIssue(testOHSSID) 41 | Expect(err).To(BeNil()) 42 | Expect(issue.ID).To(Equal(testOHSSID)) 43 | Expect(issue.ProjectKey).To(Equal(JiraOHSSProjectKey)) 44 | }) 45 | 46 | It("Should return error for issue not belongs to OHSS project", func() { 47 | 48 | nonOHSSfields := &jira.IssueFields{Project: jira.Project{Key: "NON-OHSS"}} 49 | nonOHSSIssue := jira.Issue{ID: testOHSSID, Fields: nonOHSSfields} 50 | mockIssueService.EXPECT().Get(testOHSSID, nil).Return(&nonOHSSIssue, nil, nil).Times(1) 51 | 52 | _, err := ohssService.GetIssue(testOHSSID) 53 | Expect(err).NotTo(BeNil()) 54 | Expect(err.Error()).To(Equal("issue OHSS-1000 is not belongs to OHSS project")) 55 | }) 56 | 57 | It("Should return error for empty issue", func() { 58 | 59 | mockIssueService.EXPECT().Get(testOHSSID, nil).Return(nil, nil, nil).Times(1) 60 | 61 | _, err := ohssService.GetIssue(testOHSSID) 62 | Expect(err).NotTo(BeNil()) 63 | Expect(err.Error()).To(Equal("no matching issue for issueID:OHSS-1000")) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /.github/workflows/branch-protection-check.yml: -------------------------------------------------------------------------------- 1 | name: Branch Protection Check 2 | 3 | on: 4 | schedule: 5 | # Run weekly to verify branch protection is properly configured 6 | - cron: '0 9 * * 1' # Every Monday at 9 AM UTC 7 | workflow_dispatch: # Allow manual triggering 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | verify-dependabot-config: 14 | name: Verify Dependabot Configuration 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Check Dependabot Config 22 | run: | 23 | echo "🤖 Verifying Dependabot configuration..." 24 | 25 | if [ -f ".github/dependabot.yml" ]; then 26 | echo "✅ Dependabot configuration found" 27 | echo "" 28 | echo "Configuration summary:" 29 | grep -A 10 "package-ecosystem:" .github/dependabot.yml || true 30 | else 31 | echo "❌ Dependabot configuration missing" 32 | exit 1 33 | fi 34 | 35 | verify-workflows: 36 | name: Verify Auto-Merge Workflows 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - name: Checkout code 41 | uses: actions/checkout@v4 42 | 43 | - name: Check Required Workflows 44 | run: | 45 | echo "🔄 Verifying auto-merge workflows..." 46 | 47 | required_workflows=( 48 | ".github/workflows/dependabot-auto-merge.yml" 49 | ) 50 | 51 | all_present=true 52 | 53 | for workflow in "${required_workflows[@]}"; do 54 | if [ -f "$workflow" ]; then 55 | echo "✅ $workflow found" 56 | else 57 | echo "❌ $workflow missing" 58 | all_present=false 59 | fi 60 | done 61 | 62 | if [ "$all_present" = false ]; then 63 | echo "" 64 | echo "Some required workflows are missing. Auto-merge may not work properly." 65 | exit 1 66 | fi 67 | 68 | echo "" 69 | echo "✅ All required workflows are present" 70 | echo "" 71 | echo "ℹ️ Note: Auto-merge relies entirely on existing repository CI checks" 72 | echo " GitHub's 'gh pr merge --auto' waits for all required status checks" 73 | echo " to pass before merging. No separate CI workflow is needed." 74 | -------------------------------------------------------------------------------- /pkg/ai/mcp/backplane_info.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/modelcontextprotocol/go-sdk/mcp" 9 | "github.com/openshift/backplane-cli/pkg/cli/config" 10 | "github.com/openshift/backplane-cli/pkg/info" 11 | ) 12 | 13 | // BackplaneInfoInput represents the input for the backplane-info tool 14 | type BackplaneInfoInput struct { 15 | // No input parameters needed for backplane info 16 | } 17 | 18 | // GetBackplaneInfo retrieves comprehensive information about the backplane CLI installation 19 | // and configuration 20 | func GetBackplaneInfo(ctx context.Context, req *mcp.CallToolRequest, input BackplaneInfoInput) (*mcp.CallToolResult, any, error) { 21 | // Get version information 22 | version := info.DefaultInfoService.GetVersion() 23 | 24 | // Get configuration information 25 | bpConfig, err := config.GetBackplaneConfiguration() 26 | var configInfo string 27 | if err != nil { 28 | configInfo = fmt.Sprintf("Error loading configuration: %v", err) 29 | } else { 30 | // Helper function logic inlined 31 | sessionDir := bpConfig.SessionDirectory 32 | if sessionDir == "" { 33 | sessionDir = info.BackplaneDefaultSessionDirectory 34 | } 35 | 36 | proxyURL := "not configured" 37 | if bpConfig.ProxyURL != nil && *bpConfig.ProxyURL != "" { 38 | proxyURL = *bpConfig.ProxyURL 39 | } 40 | 41 | awsProxy := "not configured" 42 | if bpConfig.AwsProxy != nil && *bpConfig.AwsProxy != "" { 43 | awsProxy = *bpConfig.AwsProxy 44 | } 45 | 46 | configInfo = fmt.Sprintf(`Configuration: 47 | - Backplane URL: %s 48 | - Session Directory: %s 49 | - Proxy URL: %s 50 | - AWS Proxy: %s 51 | - Display Cluster Info: %t 52 | - GovCloud: %t`, 53 | bpConfig.URL, 54 | sessionDir, 55 | proxyURL, 56 | awsProxy, 57 | bpConfig.DisplayClusterInfo, 58 | bpConfig.Govcloud) 59 | } 60 | 61 | // Get current working directory and environment info 62 | cwd, _ := os.Getwd() 63 | homeDir, _ := os.UserHomeDir() 64 | 65 | // Build complete info response 66 | infoText := fmt.Sprintf(`Backplane CLI Information: 67 | 68 | Version: %s 69 | 70 | %s 71 | 72 | Environment: 73 | - Home Directory: %s 74 | - Current Directory: %s 75 | - Shell: %s`, version, configInfo, homeDir, cwd, os.Getenv("SHELL")) 76 | 77 | return &mcp.CallToolResult{ 78 | Content: []mcp.Content{ 79 | &mcp.TextContent{Text: infoText}, 80 | }, 81 | }, nil, nil 82 | } 83 | -------------------------------------------------------------------------------- /docs/hotfix.md: -------------------------------------------------------------------------------- 1 | # How to make a hotfix release 2 | 3 | This document describes how to generate a hotfix release for backplane-cli. 4 | 5 | ### When to make a hotfix release 6 | 7 | - A critical bug has been found 8 | - Security vulnerabilities have been encountered 9 | - Something broke in the last/latest release 10 | 11 | ### Hotfix release process technical guidelines 12 | 13 | Please make sure to adhere to these following guidelines below for the hotfix release process : 14 | 15 | #### 1. Create an OSD card 16 | 17 | - Create an OSD card with title : **Backplane Hotfix Release for <$reason>** 18 | - In the description of the card , mention details about the intention/reason behind this hotfix. 19 | - Set the Priority of this card as High. 20 | - Add backplane-hotfix label to it. 21 | 22 | #### 2. Create pull request 23 | 24 | - Create a branch called **hotfix-$OSD-card-number** in your code repository. 25 | 26 | **NOTE** : We strongly recommend that all the changes are well tested and validated before commiting them. 27 | 28 | - ##### If the hotfix is on reverting a commit : 29 | 1. Use ```git log``` to get the commit hash of the targeted commit. 30 | 1. Once you get the commit hash , use git revert with -m flag. 31 | In git revert -m, the -m option specifies the parent number. When you view a merge commit in the output of git log, you will see its parents listed on the line that begins with **Merge:** 32 | 1. Execute ```git revert -m <$parent number> <$commit-hash> ``` 33 | 1. ```git push -u origin {hotfix-branch}``` 34 | 35 | Alternatively, you can get backplane maintainers to use GitHub's UI to revert a commit directly from the merged PR, which simplifies the process and avoids potential errors in command execution. 36 | 37 | - ##### If there are code changes to be made : 38 | 1. Make applicable code changes. 39 | 1. Raise PR for the same following the usual PR process 40 | 41 | Once you raise the PR, mention the OSD card created in step1 in the description of the PR. 42 | 43 | #### 3. Reach out to the backplane-team 44 | 45 | Once you create the PR : 46 | 47 | 1. Reach out to **@backplane-team** in #sd-ims-backplane slack channel asking for review and merge. 48 | 1. Once the PR is merged, ask **@Backplane-cli** to cut a new backplane release. 49 | 50 | #### 4. How to announce hotfix release 51 | 52 | Send an email to applicable stakeholders . Include details on the purpose behind the release. 53 | -------------------------------------------------------------------------------- /pkg/login/kubeConfig_test.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "time" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | "k8s.io/client-go/tools/clientcmd/api" 11 | ) 12 | 13 | var _ = Describe("Login Kube Config test", func() { 14 | 15 | var ( 16 | testClusterID string 17 | kubeConfig api.Config 18 | kubePath string 19 | ) 20 | 21 | BeforeEach(func() { 22 | 23 | testClusterID = "test123" 24 | kubeConfig = api.Config{ 25 | Kind: "Config", 26 | APIVersion: "v1", 27 | Preferences: api.Preferences{}, 28 | Clusters: map[string]*api.Cluster{ 29 | "dummy_cluster": { 30 | Server: "https://api-backplane.apps.something.com/backplane/cluster/configcluster", 31 | }, 32 | }, 33 | } 34 | 35 | dirName, _ := os.MkdirTemp("", ".kube") 36 | kubePath = dirName 37 | 38 | }) 39 | 40 | Context("save kubeconfig ", func() { 41 | It("should save cluster kube config in cluster folder, and replace it on second call", func() { 42 | 43 | err := SetKubeConfigBasePath(kubePath) 44 | Expect(err).To(BeNil()) 45 | 46 | path, err := CreateClusterKubeConfig(testClusterID, kubeConfig) 47 | Expect(err).To(BeNil()) 48 | Expect(path).Should(ContainSubstring(testClusterID)) 49 | 50 | //check file is exist 51 | firstStat, err := os.Stat(path) 52 | Expect(err).To(BeNil()) 53 | 54 | time.Sleep(1 * time.Second) 55 | path, err = CreateClusterKubeConfig(testClusterID, kubeConfig) 56 | Expect(err).To(BeNil()) 57 | Expect(path).Should(ContainSubstring(testClusterID)) 58 | 59 | //check file has been replaced 60 | secondStat, err := os.Stat(path) 61 | Expect(err).To(BeNil()) 62 | Expect(firstStat).ToNot(Equal(secondStat)) 63 | }) 64 | }) 65 | 66 | Context("Delete kubeconfig ", func() { 67 | It("should save cluster kube config in cluster folder", func() { 68 | 69 | err := SetKubeConfigBasePath(kubePath) 70 | Expect(err).To(BeNil()) 71 | 72 | path, err := CreateClusterKubeConfig(testClusterID, kubeConfig) 73 | Expect(err).To(BeNil()) 74 | 75 | // check file is exist 76 | _, err = os.Stat(path) 77 | Expect(err).To(BeNil()) 78 | 79 | // delete kube config 80 | err = RemoveClusterKubeConfig(testClusterID) 81 | Expect(err).To(BeNil()) 82 | 83 | // check file is not exist 84 | _, err = os.Stat(path) 85 | Expect(err).NotTo(BeNil()) 86 | Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue()) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/monitoring/monitoring.go: -------------------------------------------------------------------------------- 1 | package monitoring 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/openshift/backplane-cli/pkg/monitoring" 11 | ) 12 | 13 | var MonitoringCmd = &cobra.Command{ 14 | Use: fmt.Sprintf("monitoring <%s>", strings.Join(monitoring.ValidMonitoringNames, "|")), 15 | Short: "Create a local proxy to the monitoring UI", 16 | Long: fmt.Sprintf(`It will proxy to the monitoring UI including %s.`, strings.Join(monitoring.ValidMonitoringNames, ",")), 17 | Args: cobra.MatchAll(cobra.MinimumNArgs(1), cobra.OnlyValidArgs), 18 | ValidArgs: monitoring.ValidMonitoringNames, 19 | RunE: runMonitoring, 20 | SilenceUsage: true, 21 | } 22 | 23 | func init() { 24 | flags := MonitoringCmd.Flags() 25 | flags.BoolVarP( 26 | &monitoring.MonitoringOpts.Browser, 27 | "browser", 28 | "b", 29 | false, 30 | "Open the browser automatically.", 31 | ) 32 | flags.StringVarP( 33 | &monitoring.MonitoringOpts.Namespace, 34 | "namespace", 35 | "n", 36 | "openshift-monitoring", 37 | "Specify namespace of monitoring stack.", 38 | ) 39 | flags.StringVarP( 40 | &monitoring.MonitoringOpts.Selector, 41 | "selector", 42 | "l", 43 | "", 44 | "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2).", 45 | ) 46 | flags.StringVarP( 47 | &monitoring.MonitoringOpts.Port, 48 | "port", 49 | "p", 50 | "", 51 | "The port the remote application listens on. (Default will be picked by server based on application's conventional port.)", 52 | ) 53 | flags.StringVarP( 54 | &monitoring.MonitoringOpts.OriginURL, 55 | "origin", 56 | "u", 57 | "", 58 | "The original url. Eg, copied from the prometheus url in pagerduty. When specified, it will print the proxied url of the corresponding original url.", 59 | ) 60 | flags.StringVar( 61 | &monitoring.MonitoringOpts.ListenAddr, 62 | "listen", 63 | "", 64 | "The local address to listen to. Recommend using 127.0.0.1:xxxx to minimize security risk. The default will pick a random port on 127.0.0.1", 65 | ) 66 | 67 | } 68 | 69 | // runMonitoring create local proxy url to serve monitoring dashboard 70 | func runMonitoring(cmd *cobra.Command, argv []string) error { 71 | monitoringType := argv[0] 72 | monitoring.MonitoringOpts.KeepAlive = true 73 | client := monitoring.NewClient("", http.Client{}) 74 | return client.RunMonitoring(monitoringType) 75 | } 76 | -------------------------------------------------------------------------------- /hack/codecov.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | REPO_ROOT=$(git rev-parse --show-toplevel) 8 | CI_SERVER_URL=https://prow.svc.ci.openshift.org/view/gcs/origin-ci-test 9 | COVER_PROFILE=${COVER_PROFILE:-coverage.out} 10 | JOB_TYPE=${JOB_TYPE:-"local"} 11 | 12 | # Default concurrency to four threads. By default it's the number of procs, 13 | # which seems to be 16 in the CI env. Some consumers' coverage jobs were 14 | # regularly getting OOM-killed; so do this rather than boost the pod resources 15 | # unreasonably. 16 | COV_THREAD_COUNT=${COV_THREAD_COUNT:-4} 17 | make -C "${REPO_ROOT}" test TESTOPTS="-coverprofile=${COVER_PROFILE}.tmp -covermode=atomic -coverpkg=./... -p ${COV_THREAD_COUNT}" 18 | 19 | # Remove generated files from coverage profile 20 | grep -v "zz_generated" "${COVER_PROFILE}.tmp" > "${COVER_PROFILE}" 21 | rm -f "${COVER_PROFILE}.tmp" 22 | 23 | # Configure the git refs and job link based on how the job was triggered via prow 24 | if [[ "${JOB_TYPE}" == "presubmit" ]]; then 25 | echo "detected PR code coverage job for #${PULL_NUMBER}" 26 | REF_FLAGS="-P ${PULL_NUMBER} -C ${PULL_PULL_SHA}" 27 | JOB_LINK="${CI_SERVER_URL}/pr-logs/pull/${REPO_OWNER}_${REPO_NAME}/${PULL_NUMBER}/${JOB_NAME}/${BUILD_ID}" 28 | elif [[ "${JOB_TYPE}" == "postsubmit" ]]; then 29 | echo "detected branch code coverage job for ${PULL_BASE_REF}" 30 | REF_FLAGS="-B ${PULL_BASE_REF} -C ${PULL_BASE_SHA}" 31 | JOB_LINK="${CI_SERVER_URL}/logs/${JOB_NAME}/${BUILD_ID}" 32 | elif [[ "${JOB_TYPE}" == "local" ]]; then 33 | echo "coverage report available at ${COVER_PROFILE}" 34 | exit 0 35 | else 36 | echo "${JOB_TYPE} jobs not supported" >&2 37 | exit 1 38 | fi 39 | 40 | # Configure certain internal codecov variables with values from prow. 41 | export CI_BUILD_URL="${JOB_LINK}" 42 | export CI_BUILD_ID="${JOB_NAME}" 43 | export CI_JOB_ID="${BUILD_ID}" 44 | 45 | if [[ "${JOB_TYPE}" != "local" ]]; then 46 | if [[ -z "${ARTIFACT_DIR:-}" ]] || [[ ! -d "${ARTIFACT_DIR}" ]] || [[ ! -w "${ARTIFACT_DIR}" ]]; then 47 | echo '${ARTIFACT_DIR} must be set for non-local jobs, and must point to a writable directory' >&2 48 | exit 1 49 | fi 50 | curl -sS https://codecov.io/bash -o "${ARTIFACT_DIR}/codecov.sh" 51 | bash <(cat "${ARTIFACT_DIR}/codecov.sh") -Z -K -f "${COVER_PROFILE}" -r "${REPO_OWNER}/${REPO_NAME}" ${REF_FLAGS} 52 | else 53 | bash <(curl -s https://codecov.io/bash) -Z -K -f "${COVER_PROFILE}" -r "${REPO_OWNER}/${REPO_NAME}" ${REF_FLAGS} 54 | fi 55 | -------------------------------------------------------------------------------- /pkg/info/info.go: -------------------------------------------------------------------------------- 1 | // This file contains information about backplane-cli. 2 | 3 | package info 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | // Environment Variables 12 | BackplaneURLEnvName = "BACKPLANE_URL" 13 | BackplaneProxyEnvName = "HTTPS_PROXY" 14 | BackplaneAWSProxyEnvName = "BACKPLANE_AWS_PROXY" 15 | BackplaneConfigPathEnvName = "BACKPLANE_CONFIG" 16 | BackplaneKubeconfigEnvName = "KUBECONFIG" 17 | BackplaneJiraAPITokenEnvName = "JIRA_API_TOKEN" //nolint:gosec 18 | 19 | // Configuration 20 | BackplaneConfigDefaultFilePath = ".config/backplane" 21 | BackplaneConfigDefaultFileName = "config.json" 22 | 23 | // Session 24 | BackplaneDefaultSessionDirectory = "backplane" 25 | 26 | // GitHub API get fetch the latest tag 27 | UpstreamReleaseAPI = "https://api.github.com/repos/openshift/backplane-cli/releases/latest" 28 | 29 | // Upstream git module 30 | UpstreamGitModule = "https://github.com/openshift/backplane-cli/cmd/ocm-backplane" 31 | 32 | // GitHub README page 33 | UpstreamREADMETemplate = "https://github.com/openshift/backplane-cli/-/blob/%s/README.md" 34 | 35 | // GitHub Host 36 | GitHubHost = "github.com" 37 | 38 | // Nginx configuration template for monitoring-plugin 39 | MonitoringPluginNginxConfigTemplate = ` 40 | error_log /dev/stdout info; 41 | events {} 42 | http { 43 | include /etc/nginx/mime.types; 44 | default_type application/octet-stream; 45 | keepalive_timeout 65; 46 | server { 47 | listen %s; 48 | root /usr/share/nginx/html; 49 | } 50 | } 51 | ` 52 | 53 | MonitoringPluginNginxConfigFilename = "monitoring-plugin-nginx-%s.conf" 54 | ) 55 | 56 | var ( 57 | // Version of the backplane-cli 58 | // This will be set via Goreleaser during the build process 59 | Version string 60 | 61 | UpstreamREADMETagged = fmt.Sprintf(UpstreamREADMETemplate, Version) 62 | ) 63 | 64 | type InfoService interface { 65 | // get the current binary version from available sources 66 | GetVersion() string 67 | } 68 | 69 | type DefaultInfoServiceImpl struct { 70 | } 71 | 72 | func (i *DefaultInfoServiceImpl) GetVersion() string { 73 | // If the Version is set by Goreleaser, return it directly. 74 | if Version != "" { 75 | return Version 76 | } 77 | 78 | // otherwise, return the build info from Go build if available. 79 | buildInfo, available := DefaultBuildInfoService.GetBuildInfo() 80 | if available { 81 | return strings.TrimLeft(buildInfo.Main.Version, "v") 82 | } 83 | 84 | return "unknown" 85 | } 86 | 87 | var DefaultInfoService InfoService = &DefaultInfoServiceImpl{} 88 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Claude Code Configuration 2 | 3 | This file contains configuration and documentation for Claude Code to help with development tasks. 4 | 5 | ## Project Overview 6 | 7 | This is the backplane-cli project, a CLI tool to interact with the OpenShift backplane API. It's written in Go and provides cluster management capabilities. 8 | 9 | ## Common Commands 10 | 11 | ### Building and Development 12 | ```bash 13 | # Build the project 14 | make build 15 | 16 | # Build static binary 17 | make build-static 18 | 19 | # Install binary system-wide 20 | make install 21 | 22 | # Clean build artifacts 23 | make clean 24 | 25 | # Cross-platform builds 26 | make cross-build 27 | make cross-build-darwin-amd64 28 | make cross-build-linux-amd64 29 | ``` 30 | 31 | ### Testing and Quality 32 | ```bash 33 | # Run tests 34 | make test 35 | 36 | # Run linting 37 | make lint 38 | 39 | # Generate coverage report 40 | make coverage 41 | 42 | # Security vulnerability scan 43 | make scan 44 | 45 | # Generate mocks 46 | make mock-gen 47 | 48 | # Generate code 49 | make generate 50 | ``` 51 | 52 | ### Container Operations 53 | ```bash 54 | # Build container image 55 | make image 56 | 57 | # Build builder image 58 | make build-image 59 | 60 | # Run tests in container 61 | make test-in-container 62 | 63 | # Run lint in container 64 | make lint-in-container 65 | ``` 66 | 67 | ## Project Structure 68 | 69 | - `cmd/ocm-backplane/` - Main CLI application entry point 70 | - `pkg/` - Core packages and libraries 71 | - `internal/` - Internal packages 72 | - `vendor/` - Vendored dependencies 73 | - `docs/` - Documentation 74 | - `hack/` - Build and development scripts 75 | 76 | ## Go Configuration 77 | 78 | - **Go Version**: 1.19+ 79 | - **Module**: `github.com/openshift/backplane-cli` 80 | - **Binary Name**: `ocm-backplane` 81 | 82 | ## Linting Configuration 83 | 84 | The project uses golangci-lint with the following enabled linters: 85 | - errcheck 86 | - gosec 87 | - govet 88 | - ineffassign 89 | - staticcheck 90 | - unused 91 | 92 | ## Development Workflow 93 | 94 | 1. Make changes to code 95 | 2. Run `make lint` to check code quality 96 | 3. Run `make test` to run tests 97 | 4. Run `make build` to build the binary 98 | 5. Test the functionality manually if needed 99 | 100 | ## Important Notes 101 | 102 | - This is an OCM plugin - the binary has prefix `ocm-` and can be invoked as `ocm backplane` 103 | - Configuration file expected at `$HOME/.config/backplane/config.json` 104 | - The project uses Go modules with vendoring 105 | - Static linking is used for the final binary 106 | -------------------------------------------------------------------------------- /pkg/awsutil/iam.go: -------------------------------------------------------------------------------- 1 | package awsutil 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | ) 8 | 9 | const ( 10 | PolicyVersion = "2012-10-17" 11 | ) 12 | 13 | type PolicyDocument struct { 14 | Version string `json:"Version"` 15 | Statement []PolicyStatement `json:"Statement"` 16 | } 17 | 18 | type PolicyStatement struct { 19 | Sid string `json:"Sid"` // Statement ID 20 | Effect string `json:"Effect"` // Allow or Deny 21 | Action []string `json:"Action"` // allowed or denied action 22 | Principal map[string]string `json:",omitempty"` // principal that is allowed or denied 23 | Resource *string `json:",omitempty"` // object or objects that the statement covers 24 | Condition *Condition `json:",omitempty"` // conditions for when a policy is in effect 25 | } 26 | 27 | type PolicyDocumentInterface interface { 28 | String() (string, error) 29 | BuildPolicyWithRestrictedIP(ipAddress IPAddress) (PolicyDocument, error) 30 | } 31 | 32 | type Condition struct { 33 | //nolint NotIpAddress is required from AWS Policy 34 | NotIpAddress IPAddress `json:"NotIpAddress"` 35 | } 36 | 37 | type IPAddress struct { 38 | //nolint SourceIp is required from AWS Policy 39 | SourceIp []string `json:"aws:SourceIp"` 40 | } 41 | 42 | func NewPolicyDocument(version string, statements []PolicyStatement) PolicyDocument { 43 | return PolicyDocument{ 44 | Version: version, 45 | Statement: statements, 46 | } 47 | } 48 | 49 | func (p PolicyDocument) String() string { 50 | policyBytes, _ := json.Marshal(p) 51 | 52 | return string(policyBytes) 53 | } 54 | 55 | func (p PolicyDocument) BuildPolicyWithRestrictedIP(ipAddress IPAddress) (PolicyDocument, error) { 56 | condition := Condition{ 57 | NotIpAddress: ipAddress, 58 | } 59 | 60 | allAllow := NewPolicyStatement("AllowAll", "Allow", []string{"*"}). 61 | AddResource(aws.String("*")). 62 | AddCondition(nil) 63 | denyNonRHProxy := NewPolicyStatement("DenyNonRHProxy", "Deny", []string{"*"}). 64 | AddResource(aws.String("*")). 65 | AddCondition(&condition) 66 | p.Statement = []PolicyStatement{denyNonRHProxy, allAllow} 67 | return p, nil 68 | } 69 | 70 | func NewPolicyStatement(sid string, affect string, action []string) PolicyStatement { 71 | return PolicyStatement{ 72 | Sid: sid, 73 | Effect: affect, 74 | Action: action, 75 | } 76 | } 77 | 78 | func (ps PolicyStatement) AddResource(resource *string) PolicyStatement { 79 | ps.Resource = resource 80 | return ps 81 | } 82 | 83 | func (ps PolicyStatement) AddCondition(condition *Condition) PolicyStatement { 84 | ps.Condition = condition 85 | return ps 86 | } 87 | -------------------------------------------------------------------------------- /pkg/credentials/aws.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" 11 | "github.com/aws/aws-sdk-go-v2/config" 12 | "github.com/aws/aws-sdk-go-v2/credentials" 13 | bpconfig "github.com/openshift/backplane-cli/pkg/cli/config" 14 | ) 15 | 16 | const ( 17 | // AwsCredentialsStringFormat format strings for printing AWS credentials as a string or as environment variables 18 | AwsCredentialsStringFormat = `Temporary Credentials: 19 | AccessKeyID: %s 20 | SecretAccessKey: %s 21 | SessionToken: %s 22 | Region: %s 23 | Expires: %s` 24 | AwsExportFormat = `export AWS_ACCESS_KEY_ID=%s 25 | export AWS_SECRET_ACCESS_KEY=%s 26 | export AWS_SESSION_TOKEN=%s 27 | export AWS_DEFAULT_REGION=%s 28 | export AWS_REGION=%s` 29 | ) 30 | 31 | type AWSCredentialsResponse struct { 32 | AccessKeyID string `json:"AccessKeyID" yaml:"AccessKeyID"` 33 | SecretAccessKey string `json:"SecretAccessKey" yaml:"SecretAccessKey"` 34 | SessionToken string `json:"SessionToken" yaml:"SessionToken"` 35 | Region string `json:"Region" yaml:"Region"` 36 | Expiration string `json:"Expiration" yaml:"Expiration"` 37 | } 38 | 39 | func (r *AWSCredentialsResponse) String() string { 40 | return fmt.Sprintf(AwsCredentialsStringFormat, r.AccessKeyID, r.SecretAccessKey, r.SessionToken, r.Region, r.Expiration) 41 | } 42 | 43 | func (r *AWSCredentialsResponse) FmtExport() string { 44 | return fmt.Sprintf(AwsExportFormat, r.AccessKeyID, r.SecretAccessKey, r.SessionToken, r.Region, r.Region) 45 | } 46 | 47 | // AWSV2Config returns an aws-sdk-go-v2 config that can be used to programmatically access the AWS API 48 | func (r *AWSCredentialsResponse) AWSV2Config() (aws.Config, error) { 49 | bpConfig, err := bpconfig.GetBackplaneConfiguration() 50 | if err != nil { 51 | return aws.Config{}, fmt.Errorf("failed to load backplane config file: %w", err) 52 | } 53 | 54 | if bpConfig.ProxyURL != nil { 55 | proxyURL, err := url.Parse(*bpConfig.ProxyURL) 56 | if err != nil { 57 | return aws.Config{}, fmt.Errorf("failed to parse proxy_url from backplane config file: %w", err) 58 | } 59 | 60 | httpClient := awshttp.NewBuildableClient().WithTransportOptions(func(tr *http.Transport) { 61 | tr.Proxy = http.ProxyURL(proxyURL) 62 | }) 63 | 64 | return config.LoadDefaultConfig(context.Background(), 65 | config.WithHTTPClient(httpClient), 66 | config.WithRegion(r.Region), 67 | config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(r.AccessKeyID, r.SecretAccessKey, r.SessionToken)), 68 | ) 69 | } 70 | 71 | return config.LoadDefaultConfig(context.Background(), 72 | config.WithRegion(r.Region), 73 | config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(r.AccessKeyID, r.SecretAccessKey, r.SessionToken)), 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /pkg/healthcheck/check_proxy.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | 8 | logger "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // CheckProxyConnectivity checks the proxy connectivity 12 | func CheckProxyConnectivity(client HTTPClient) (string, error) { 13 | logger.Debug("Starting CheckProxyConnectivity") 14 | bpConfig, err := GetConfigFunc() 15 | if err != nil { 16 | logger.Errorf("Failed to get backplane configuration: %v", err) 17 | return "", fmt.Errorf("failed to get backplane configuration: %v", err) 18 | } 19 | logger.Debugf("Backplane configuration: %+v", bpConfig) 20 | 21 | proxyURL := bpConfig.ProxyURL 22 | if proxyURL == nil || *proxyURL == "" { 23 | errMsg := "no proxy URL configured in backplane configuration" 24 | logger.Warn(errMsg) 25 | return "", fmt.Errorf("%s", errMsg) 26 | } 27 | 28 | logger.Infof("Getting the working proxy URL ['%s'] from local backplane configuration.", *proxyURL) 29 | 30 | parsedProxyURL, err := url.Parse(*proxyURL) 31 | if err != nil { 32 | logger.Errorf("Invalid proxy URL: %v", err) 33 | return "", fmt.Errorf("invalid proxy URL: %v", err) 34 | } 35 | logger.Debugf("Parsed proxy URL: %s", parsedProxyURL) 36 | 37 | httpClientWithProxy := &DefaultHTTPClientImpl{ 38 | Client: &http.Client{ 39 | Transport: &http.Transport{ 40 | Proxy: http.ProxyURL(parsedProxyURL), 41 | }, 42 | }, 43 | } 44 | logger.Debug("HTTP client with proxy configured") 45 | 46 | proxyTestEndpoint, err := GetProxyTestEndpointFunc() 47 | if err != nil { 48 | logger.Errorf("Failed to get proxy test endpoint: %v", err) 49 | return "", err 50 | } 51 | logger.Debugf("Proxy test endpoint: %s", proxyTestEndpoint) 52 | 53 | logger.Infof("Testing connectivity to the pre-defined test endpoint ['%s'] with the proxy.", proxyTestEndpoint) 54 | if err := testEndPointConnectivity(proxyTestEndpoint, httpClientWithProxy); err != nil { 55 | errMsg := fmt.Sprintf("Failed to access target endpoint ['%s'] with the proxy: %v", proxyTestEndpoint, err) 56 | logger.Errorf("%s", errMsg) 57 | return "", fmt.Errorf("%s", errMsg) 58 | } 59 | 60 | logger.Debugf("Successfully connected to proxy test endpoint: %s", proxyTestEndpoint) 61 | return *proxyURL, nil 62 | } 63 | 64 | // GetProxyTestEndpoint retrieves the proxy test endpoint from the backplane configuration. 65 | // Returns an error if the configuration cannot be loaded or the endpoint is not configured. 66 | func GetProxyTestEndpoint() (string, error) { 67 | bpConfig, err := GetConfigFunc() 68 | if err != nil { 69 | logger.Errorf("Failed to get backplane configuration: %v", err) 70 | return "", fmt.Errorf("failed to get backplane configuration: %v", err) 71 | } 72 | if bpConfig.ProxyCheckEndpoint == "" { 73 | errMsg := "proxy test endpoint not configured" 74 | logger.Warn(errMsg) 75 | return "", fmt.Errorf("%s", errMsg) 76 | } 77 | return bpConfig.ProxyCheckEndpoint, nil 78 | } 79 | -------------------------------------------------------------------------------- /docs/PS1-setup.md: -------------------------------------------------------------------------------- 1 | # How to setup PS1 in bash/zsh 2 | 3 | You can use the below methods to set the shell prompt, so you can easily see which cluster you are connected to, like: 4 | ~~~ 5 | [user@user ~ (⎈ |stg/user-test-1.zlob.s1:default)]$ date 6 | Tue Sep 7 17:40:35 CST 2021 7 | [user@user ~ (⎈ |stg/user-test-1.zlob.s1:default)]$ oc whoami 8 | system:serviceaccount:default:xxxxxxxxxxxx 9 | ~~~ 10 | 11 | ## Bash 12 | Save the [kube-ps1](https://raw.githubusercontent.com/jonmosco/kube-ps1/master/kube-ps1.sh) script to local, and append the below to `~/.bashrc`. 13 | ~~~ 14 | source /path/to/kube-ps1.sh ##<---- replace this to the kube-ps1 location 15 | function cluster_function() { 16 | info="$(ocm backplane status 2> /dev/null)" 17 | if [ $? -ne 0 ]; then return; fi 18 | clustername=$(grep "Cluster Name" <<< $info | awk '{print $3}') 19 | baseid=$(grep "Cluster Basedomain" <<< $info | awk '{print $3}' | cut -d'.' -f1,2) 20 | echo $clustername.$baseid 21 | } 22 | KUBE_PS1_BINARY=oc 23 | export KUBE_PS1_CLUSTER_FUNCTION=cluster_function 24 | PS1='[\u@\h \W $(kube_ps1)]\$ ' 25 | ~~~ 26 | 27 | ## Zsh 28 | 29 | ### With `oh-my-zsh` enabled 30 | kube-ps1 is included as a plugin in the oh-my-zsh project. To enable it, edit your `~/.zshrc` and add the plugin: 31 | 32 | ``` 33 | plugins=( 34 | kube-ps1 35 | ) 36 | ``` 37 | 38 | Save the [kube-ps1](https://raw.githubusercontent.com/jonmosco/kube-ps1/master/kube-ps1.sh) script to local, and append the below to `~/.zshrc`. 39 | ~~~ 40 | source /path/to/kube-ps1.sh ##<---- replace this to your location 41 | function cluster_function() { 42 | info="$(ocm backplane status 2> /dev/null)" 43 | if [ $? -ne 0 ]; then return; fi 44 | clustername=$(grep "Cluster Name" <<< $info | awk '{print $3}') 45 | baseid=$(grep "Cluster Basedomain" <<< $info | awk '{print $3}' | cut -d'.' -f1,2) 46 | echo $clustername.$baseid 47 | } 48 | KUBE_PS1_BINARY=oc 49 | export KUBE_PS1_CLUSTER_FUNCTION=cluster_function 50 | PROMPT='$(kube_ps1)'$PROMPT 51 | ~~~ 52 | ### Without `oh-my-zsh` enabled 53 | Save the [kube-ps1](https://raw.githubusercontent.com/jonmosco/kube-ps1/master/kube-ps1.sh) script to local, and append the below to `~/.zshrc`. 54 | ~~~ 55 | source /path/to/kube-ps1.sh ##<---- replace this to your location 56 | function cluster_function() { 57 | info="$(ocm backplane status 2> /dev/null)" 58 | if [ $? -ne 0 ]; then return; fi 59 | clustername=$(grep "Cluster Name" <<< $info | awk '{print $3}') 60 | baseid=$(grep "Cluster Basedomain" <<< $info | awk '{print $3}' | cut -d'.' -f1,2) 61 | echo $clustername.$baseid 62 | } 63 | KUBE_PS1_BINARY=oc 64 | export KUBE_PS1_CLUSTER_FUNCTION=cluster_function 65 | PS1='[\u@\h \W $(kube_ps1)]\$ ' 66 | ~~~ 67 | 68 | 69 | ## Disabling warning when PS1 is not configured 70 | 71 | If you would like to disable warnings from `ocm-backplane` when `kube-ps1` is not configured, you can set the 72 | `disable-kube-ps1-warning` value to `false` in your configuration file. 73 | -------------------------------------------------------------------------------- /pkg/awsutil/iam_test.go: -------------------------------------------------------------------------------- 1 | package awsutil 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | var _ = Describe("AWS IAM Util tests", func() { 12 | 13 | BeforeEach(func() { 14 | 15 | }) 16 | 17 | AfterEach(func() { 18 | 19 | }) 20 | 21 | Context("Test IAM policy document", func() { 22 | 23 | It("Should return string policy", func() { 24 | statements := []PolicyStatement{ 25 | { 26 | Sid: "AllowAll", 27 | Effect: "Allow", 28 | Action: []string{"*"}, 29 | Resource: aws.String("*"), 30 | Condition: nil, 31 | }, 32 | } 33 | expectedRawPolicy := `{"Version":"2012-10-17","Statement":[{"Sid":"AllowAll","Effect":"Allow","Action":["*"],"Resource":"*"}]}` 34 | 35 | policy := NewPolicyDocument(PolicyVersion, statements) 36 | rawPolicy := policy.String() 37 | Expect(rawPolicy).NotTo(BeNil()) 38 | Expect(rawPolicy).To(Equal(expectedRawPolicy)) 39 | }) 40 | 41 | It("Should return All Allow policy", func() { 42 | 43 | statement := NewPolicyStatement("AllowAll", "Allow", []string{"*"}). 44 | AddResource(aws.String("*")). 45 | AddCondition(nil) 46 | 47 | expectedRawPolicy := `{"Version":"2012-10-17","Statement":[{"Sid":"AllowAll","Effect":"Allow","Action":["*"],"Resource":"*"}]}` 48 | 49 | policy := NewPolicyDocument(PolicyVersion, []PolicyStatement{statement}) 50 | rawPolicy := policy.String() 51 | Expect(statement).NotTo(BeNil()) 52 | Expect(rawPolicy).To(Equal(expectedRawPolicy)) 53 | 54 | }) 55 | 56 | It("Should return All Deny Policy", func() { 57 | 58 | statement := NewPolicyStatement("AllowDeny", "Deny", []string{"*"}). 59 | AddResource(aws.String("*")). 60 | AddCondition(nil) 61 | 62 | expectedRawPolicy := `{"Version":"2012-10-17","Statement":[{"Sid":"AllowDeny","Effect":"Deny","Action":["*"],"Resource":"*"}]}` 63 | 64 | policy := NewPolicyDocument(PolicyVersion, []PolicyStatement{statement}) 65 | rawPolicy := policy.String() 66 | Expect(statement).NotTo(BeNil()) 67 | Expect(rawPolicy).To(Equal(expectedRawPolicy)) 68 | }) 69 | It("Should return restricted IP policy", func() { 70 | 71 | expectedRawPolicy := `{"Version":"2012-10-17","Statement":[{"Sid":"DenyNonRHProxy","Effect":"Deny","Action":["*"],"Resource":"*",` + 72 | `"Condition":{"NotIpAddress":{"aws:SourceIp":["100.10.10.10"]}}},{"Sid":"AllowAll","Effect":"Allow","Action":["*"],"Resource":"*"}]}` 73 | sourceIPList := []string{"100.10.10.10"} 74 | 75 | ipAddress := IPAddress{SourceIp: sourceIPList} 76 | policy := NewPolicyDocument(PolicyVersion, []PolicyStatement{}) 77 | 78 | policy, err := policy.BuildPolicyWithRestrictedIP(ipAddress) 79 | Expect(err).To(BeNil()) 80 | rawPolicy := policy.String() 81 | fmt.Print(rawPolicy) 82 | Expect(err).To(BeNil()) 83 | Expect(rawPolicy).To(Equal(expectedRawPolicy)) 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/cloud/console_test.go: -------------------------------------------------------------------------------- 1 | package cloud 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | 9 | "go.uber.org/mock/gomock" 10 | . "github.com/onsi/ginkgo/v2" 11 | log "github.com/sirupsen/logrus" 12 | "k8s.io/client-go/tools/clientcmd" 13 | "k8s.io/client-go/tools/clientcmd/api" 14 | 15 | "github.com/openshift/backplane-cli/pkg/backplaneapi" 16 | backplaneapiMock "github.com/openshift/backplane-cli/pkg/backplaneapi/mocks" 17 | "github.com/openshift/backplane-cli/pkg/client/mocks" 18 | "github.com/openshift/backplane-cli/pkg/info" 19 | "github.com/openshift/backplane-cli/pkg/ocm" 20 | ocmMock "github.com/openshift/backplane-cli/pkg/ocm/mocks" 21 | ) 22 | 23 | var _ = Describe("Cloud console command", func() { 24 | 25 | var ( 26 | mockCtrl *gomock.Controller 27 | mockClientWithResp *mocks.MockClientWithResponsesInterface 28 | mockOcmInterface *ocmMock.MockOCMInterface 29 | mockClientUtil *backplaneapiMock.MockClientUtils 30 | 31 | proxyURI string 32 | consoleAWSURL string 33 | consoleGcloudURL string 34 | 35 | fakeAWSResp *http.Response 36 | fakeGCloudResp *http.Response 37 | ) 38 | 39 | BeforeEach(func() { 40 | mockCtrl = gomock.NewController(GinkgoT()) 41 | mockClientWithResp = mocks.NewMockClientWithResponsesInterface(mockCtrl) 42 | 43 | mockOcmInterface = ocmMock.NewMockOCMInterface(mockCtrl) 44 | ocm.DefaultOCMInterface = mockOcmInterface 45 | 46 | mockClientUtil = backplaneapiMock.NewMockClientUtils(mockCtrl) 47 | backplaneapi.DefaultClientUtils = mockClientUtil 48 | 49 | proxyURI = "https://shard.apps" 50 | consoleAWSURL = "https://signin.aws.amazon.com/federation?Action=login" 51 | consoleGcloudURL = "https://cloud.google.com/" 52 | 53 | mockClientWithResp.EXPECT().LoginClusterWithResponse(gomock.Any(), gomock.Any()).Return(nil, nil).Times(0) 54 | 55 | // Define fake AWS response 56 | fakeAWSResp = &http.Response{ 57 | Body: MakeIoReader( 58 | fmt.Sprintf(`{"proxy_uri":"proxy", "message":"msg", "ConsoleLink":"%s"}`, consoleAWSURL), 59 | ), 60 | Header: map[string][]string{}, 61 | StatusCode: http.StatusOK, 62 | } 63 | fakeAWSResp.Header.Add("Content-Type", "json") 64 | 65 | // Define fake AWS response 66 | fakeGCloudResp = &http.Response{ 67 | Body: MakeIoReader( 68 | fmt.Sprintf(`{"proxy_uri":"proxy", "message":"msg", "ConsoleLink":"%s"}`, consoleGcloudURL), 69 | ), 70 | Header: map[string][]string{}, 71 | StatusCode: http.StatusOK, 72 | } 73 | fakeGCloudResp.Header.Add("Content-Type", "json") 74 | 75 | // Clear config file 76 | _ = clientcmd.ModifyConfig(clientcmd.NewDefaultPathOptions(), api.Config{}, true) 77 | clientcmd.UseModifyConfigLock = false 78 | 79 | // Disabled log output 80 | log.SetOutput(io.Discard) 81 | _ = os.Setenv(info.BackplaneURLEnvName, proxyURI) 82 | }) 83 | 84 | AfterEach(func() { 85 | _ = os.Setenv(info.BackplaneURLEnvName, "") 86 | mockCtrl.Finish() 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /pkg/ai/mcp/backplane_cloud_console.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/modelcontextprotocol/go-sdk/mcp" 10 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/cloud" 11 | ) 12 | 13 | type BackplaneCloudConsoleArgs struct { 14 | ClusterID string `json:"clusterId" jsonschema:"description:the cluster ID for backplane cloud console"` 15 | } 16 | 17 | func BackplaneCloudConsole(ctx context.Context, request *mcp.CallToolRequest, input BackplaneCloudConsoleArgs) (*mcp.CallToolResult, any, error) { 18 | clusterID := strings.TrimSpace(input.ClusterID) 19 | if clusterID == "" { 20 | return &mcp.CallToolResult{ 21 | Content: []mcp.Content{ 22 | &mcp.TextContent{Text: "Error: Cluster ID is required for backplane cloud console"}, 23 | }, 24 | }, nil, fmt.Errorf("cluster ID cannot be empty") 25 | } 26 | 27 | // Create cloud console command and configure it 28 | consoleCmd := cloud.ConsoleCmd 29 | 30 | // Set up command arguments 31 | args := []string{clusterID} 32 | consoleCmd.SetArgs(args) 33 | 34 | // Always open in browser when using MCP 35 | err := consoleCmd.Flags().Set("browser", "true") 36 | if err != nil { 37 | return &mcp.CallToolResult{ 38 | Content: []mcp.Content{ 39 | &mcp.TextContent{Text: fmt.Sprintf("Error setting browser flag: %v", err)}, 40 | }, 41 | }, nil, nil 42 | } 43 | 44 | // Set output format to json for better parsing 45 | err = consoleCmd.Flags().Set("output", "json") 46 | if err != nil { 47 | return &mcp.CallToolResult{ 48 | Content: []mcp.Content{ 49 | &mcp.TextContent{Text: fmt.Sprintf("Error setting output flag: %v", err)}, 50 | }, 51 | }, nil, nil 52 | } 53 | 54 | // Run the cloud console command in a background goroutine to avoid blocking 55 | errChan := make(chan error, 1) 56 | go func() { 57 | errChan <- consoleCmd.RunE(consoleCmd, args) 58 | }() 59 | 60 | // Wait briefly to see if there's an immediate error (e.g., login required, invalid cluster) 61 | select { 62 | case err := <-errChan: 63 | // Command failed quickly - likely a configuration/validation error 64 | errorMessage := fmt.Sprintf("Failed to get cloud console for cluster '%s'. Error: %v", clusterID, err) 65 | return &mcp.CallToolResult{ 66 | Content: []mcp.Content{ 67 | &mcp.TextContent{Text: errorMessage}, 68 | }, 69 | }, nil, nil 70 | case <-time.After(3 * time.Second): 71 | // No immediate error - cloud console is starting up successfully 72 | // The goroutine continues running in the background 73 | } 74 | 75 | // Build success message 76 | var successMessage strings.Builder 77 | successMessage.WriteString(fmt.Sprintf("✅ Cloud console access retrieved for cluster '%s'\n\n", clusterID)) 78 | successMessage.WriteString("🌐 Cloud console will open in your default browser when ready\n") 79 | successMessage.WriteString("\n⚠️ Note: The cloud console command is running in the background") 80 | 81 | return &mcp.CallToolResult{ 82 | Content: []mcp.Content{ 83 | &mcp.TextContent{Text: successMessage.String()}, 84 | }, 85 | }, nil, nil 86 | } 87 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/testJob/getTestJobLogs.go: -------------------------------------------------------------------------------- 1 | package testjob 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | bpClient "github.com/openshift/backplane-api/pkg/client" 13 | 14 | "github.com/openshift/backplane-cli/pkg/backplaneapi" 15 | "github.com/openshift/backplane-cli/pkg/cli/config" 16 | "github.com/openshift/backplane-cli/pkg/ocm" 17 | "github.com/openshift/backplane-cli/pkg/utils" 18 | ) 19 | 20 | func newGetTestJobLogsCommand() *cobra.Command { 21 | 22 | cmd := &cobra.Command{ 23 | Use: "logs ", 24 | Aliases: []string{"log"}, 25 | Short: "Get a backplane testJob logs", 26 | Args: cobra.ExactArgs(1), 27 | SilenceUsage: true, 28 | SilenceErrors: true, 29 | RunE: runGetTestJobLogs, 30 | } 31 | 32 | return cmd 33 | } 34 | 35 | func runGetTestJobLogs(cmd *cobra.Command, args []string) error { 36 | // ======== Parsing Flags ======== 37 | // Cluster ID flag 38 | clusterKey, err := cmd.Flags().GetString("cluster-id") 39 | if err != nil { 40 | return err 41 | } 42 | 43 | // URL flag 44 | urlFlag, err := cmd.Flags().GetString("url") 45 | if err != nil { 46 | return err 47 | } 48 | 49 | // raw flag 50 | rawFlag, err := cmd.Flags().GetBool("raw") 51 | if err != nil { 52 | return err 53 | } 54 | 55 | logFlag, err := cmd.Flags().GetBool("follow") 56 | if err != nil { 57 | return err 58 | } 59 | // ======== Initialize backplaneURL ======== 60 | bpConfig, err := config.GetBackplaneConfiguration() 61 | if err != nil { 62 | return err 63 | } 64 | 65 | bpCluster, err := utils.DefaultClusterUtils.GetBackplaneCluster(clusterKey) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | // Check if the cluster is hibernating 71 | isClusterHibernating, err := ocm.DefaultOCMInterface.IsClusterHibernating(bpCluster.ClusterID) 72 | if err == nil && isClusterHibernating { 73 | // Hibernating, print out error and skip 74 | return fmt.Errorf("cluster %s is hibernating, not creating ManagedJob", bpCluster.ClusterID) 75 | } 76 | 77 | backplaneHost := bpConfig.URL 78 | 79 | clusterID := bpCluster.ClusterID 80 | 81 | if urlFlag != "" { 82 | backplaneHost = urlFlag 83 | } 84 | 85 | // It is always 1 in length, enforced by cobra 86 | testID := args[0] 87 | 88 | client, err := backplaneapi.DefaultClientUtils.MakeRawBackplaneAPIClient(backplaneHost) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | // ======== Call Endpoint ======== 94 | version := "v2" 95 | resp, err := client.GetTestScriptRunLogs(context.TODO(), clusterID, testID, &bpClient.GetTestScriptRunLogsParams{Version: &version, Follow: &logFlag}) 96 | 97 | // ======== Render Results ======== 98 | if err != nil { 99 | return err 100 | } 101 | 102 | if resp.StatusCode != http.StatusOK { 103 | return utils.TryPrintAPIError(resp, rawFlag) 104 | } 105 | 106 | _, err = io.Copy(os.Stdout, resp.Body) 107 | if err != nil { 108 | return err 109 | } 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /pkg/ai/mcp/backplane_console.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/modelcontextprotocol/go-sdk/mcp" 10 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/console" 11 | ) 12 | 13 | type BackplaneConsoleArgs struct { 14 | ClusterID string `json:"clusterId" jsonschema:"description:the cluster ID for backplane console"` 15 | } 16 | 17 | func BackplaneConsole(ctx context.Context, request *mcp.CallToolRequest, input BackplaneConsoleArgs) (*mcp.CallToolResult, any, error) { 18 | clusterID := strings.TrimSpace(input.ClusterID) 19 | if clusterID == "" { 20 | return &mcp.CallToolResult{ 21 | Content: []mcp.Content{ 22 | &mcp.TextContent{Text: "Error: Cluster ID is required for backplane console access"}, 23 | }, 24 | }, nil, fmt.Errorf("cluster ID cannot be empty") 25 | } 26 | 27 | // Create console command and configure it 28 | consoleCmd := console.NewConsoleCmd() 29 | 30 | // Set up command arguments 31 | args := []string{clusterID} 32 | consoleCmd.SetArgs(args) 33 | 34 | // Always open in browser when using MCP 35 | err := consoleCmd.Flags().Set("browser", "true") 36 | if err != nil { 37 | return &mcp.CallToolResult{ 38 | Content: []mcp.Content{ 39 | &mcp.TextContent{Text: fmt.Sprintf("Error setting browser flag: %v", err)}, 40 | }, 41 | }, nil, nil 42 | } 43 | 44 | // Run the console command in a background goroutine to avoid blocking 45 | // The console command blocks indefinitely waiting for Ctrl+C 46 | errChan := make(chan error, 1) 47 | go func() { 48 | errChan <- consoleCmd.RunE(consoleCmd, args) 49 | }() 50 | 51 | // Wait briefly to see if there's an immediate error (e.g., login required, invalid cluster) 52 | select { 53 | case err := <-errChan: 54 | // Command failed quickly - likely a configuration/validation error 55 | errorMessage := fmt.Sprintf("Failed to start console for cluster '%s'. Error: %v", clusterID, err) 56 | return &mcp.CallToolResult{ 57 | Content: []mcp.Content{ 58 | &mcp.TextContent{Text: errorMessage}, 59 | }, 60 | }, nil, nil 61 | case <-time.After(5 * time.Second): 62 | // No immediate error - console is starting up successfully 63 | // The goroutine continues running in the background 64 | } 65 | 66 | // Build success message 67 | var successMessage strings.Builder 68 | successMessage.WriteString(fmt.Sprintf("✅ Console is starting for cluster '%s'\n\n", clusterID)) 69 | successMessage.WriteString("🌐 Console will open in your default browser when ready\n\n") 70 | successMessage.WriteString("⚠️ IMPORTANT:\n") 71 | successMessage.WriteString("- The console is running in the background\n") 72 | successMessage.WriteString("- To stop it, manually stop the containers:\n") 73 | successMessage.WriteString(fmt.Sprintf(" podman stop console-%s monitoring-plugin-%s\n", clusterID, clusterID)) 74 | successMessage.WriteString(fmt.Sprintf(" OR: docker stop console-%s monitoring-plugin-%s", clusterID, clusterID)) 75 | 76 | return &mcp.CallToolResult{ 77 | Content: []mcp.Content{ 78 | &mcp.TextContent{Text: successMessage.String()}, 79 | }, 80 | }, nil, nil 81 | } 82 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/testJob/getTestJob.go: -------------------------------------------------------------------------------- 1 | package testjob 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | backplaneApi "github.com/openshift/backplane-api/pkg/client" 11 | 12 | "github.com/openshift/backplane-cli/pkg/backplaneapi" 13 | "github.com/openshift/backplane-cli/pkg/cli/config" 14 | "github.com/openshift/backplane-cli/pkg/ocm" 15 | "github.com/openshift/backplane-cli/pkg/utils" 16 | ) 17 | 18 | func newGetTestJobCommand() *cobra.Command { 19 | 20 | cmd := &cobra.Command{ 21 | Use: "get ", 22 | Short: "Get a backplane testjob resource", 23 | Args: cobra.ExactArgs(1), 24 | SilenceUsage: true, 25 | SilenceErrors: true, 26 | RunE: runGetTestJob, 27 | } 28 | 29 | return cmd 30 | } 31 | 32 | func runGetTestJob(cmd *cobra.Command, args []string) error { 33 | // ======== Parsing Flags ======== 34 | // Cluster ID flag 35 | clusterKey, err := cmd.Flags().GetString("cluster-id") 36 | if err != nil { 37 | return err 38 | } 39 | 40 | // URL flag 41 | urlFlag, err := cmd.Flags().GetString("url") 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // raw flag 47 | rawFlag, err := cmd.Flags().GetBool("raw") 48 | if err != nil { 49 | return err 50 | } 51 | // ======== Initialize backplaneURL ======== 52 | bpConfig, err := config.GetBackplaneConfiguration() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | bpCluster, err := utils.DefaultClusterUtils.GetBackplaneCluster(clusterKey) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | // Check if the cluster is hibernating 63 | isClusterHibernating, err := ocm.DefaultOCMInterface.IsClusterHibernating(bpCluster.ClusterID) 64 | if err == nil && isClusterHibernating { 65 | // Hibernating, print out error and skip 66 | return fmt.Errorf("cluster %s is hibernating, not creating ManagedJob", bpCluster.ClusterID) 67 | } 68 | 69 | backplaneHost := bpConfig.URL 70 | 71 | clusterID := bpCluster.ClusterID 72 | 73 | if urlFlag != "" { 74 | backplaneHost = urlFlag 75 | } 76 | 77 | // It is always 1 in length, enforced by cobra 78 | testID := args[0] 79 | 80 | client, err := backplaneapi.DefaultClientUtils.MakeRawBackplaneAPIClient(backplaneHost) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | // ======== Call Endpoint ======== 86 | resp, err := client.GetTestScriptRun(context.TODO(), clusterID, testID) 87 | 88 | // ======== Render Results ======== 89 | if err != nil { 90 | return err 91 | } 92 | 93 | if resp.StatusCode != http.StatusOK { 94 | return utils.TryPrintAPIError(resp, rawFlag) 95 | } 96 | 97 | createResp, err := backplaneApi.ParseGetTestScriptRunResponse(resp) 98 | 99 | if err != nil { 100 | return fmt.Errorf("unable to parse response body from backplane: \n Status Code: %d", resp.StatusCode) 101 | } 102 | 103 | fmt.Printf("TestId: %s, Status: %s\n", createResp.JSON200.TestId, *createResp.JSON200.Status) 104 | 105 | if rawFlag { 106 | _ = utils.RenderJSONBytes(createResp.JSON200) 107 | } 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /pkg/elevate/elevate.go: -------------------------------------------------------------------------------- 1 | package elevate 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | 8 | logger "github.com/sirupsen/logrus" 9 | 10 | "github.com/openshift/backplane-cli/pkg/login" 11 | "github.com/openshift/backplane-cli/pkg/utils" 12 | ) 13 | 14 | var ( 15 | OsRemove = os.Remove 16 | ExecCmd = exec.Command 17 | ReadKubeConfigRaw = utils.ReadKubeconfigRaw 18 | WriteKubeconfigToFile = utils.CreateTempKubeConfig 19 | ) 20 | 21 | // RunElevate executes the elevation process for backplane access. 22 | // It reads the current kubeconfig, adds elevation context with the provided reason, 23 | // and optionally executes a command with elevated permissions. 24 | // The first argument is the elevation reason, remaining arguments are the command to execute. 25 | func RunElevate(argv []string) error { 26 | logger.Debugln("Finding target cluster from kubeconfig") 27 | config, err := ReadKubeConfigRaw() 28 | if err != nil { 29 | return err 30 | } 31 | 32 | logger.Debug("Compute and store reason from/to kubeconfig ElevateContext") 33 | var elevateReason string 34 | if len(argv) == 0 { 35 | elevateReason = "" 36 | } else { 37 | elevateReason = argv[0] 38 | } 39 | elevationReasons, err := login.SaveElevateContextReasons(config, elevateReason) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | // If no command are provided, then we just initiate elevate context 45 | if len(argv) < 2 { 46 | return nil 47 | } 48 | 49 | logger.Debug("Adding impersonation RBAC allow permissions to kubeconfig") 50 | err = login.AddElevationReasonsToRawKubeconfig(config, elevationReasons) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | // As WriteKubeconfigToFile(utils.CreateTempKubeConfig) is overriding KUBECONFIG, 56 | // we need to store its definition in order to redefine it to its original (value or unset) when we do not need it anymore 57 | oldKubeconfigPath, oldKubeconfigDefined := os.LookupEnv("KUBECONFIG") 58 | defer func() { 59 | if oldKubeconfigDefined { 60 | logger.Debugln("Will set KUBECONFIG variable to original", oldKubeconfigPath) 61 | _ = os.Setenv("KUBECONFIG", oldKubeconfigPath) 62 | } else { 63 | logger.Debugln("Will unset KUBECONFIG variable") 64 | _ = os.Unsetenv("KUBECONFIG") 65 | } 66 | }() 67 | 68 | err = WriteKubeconfigToFile(&config) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | // As WriteKubeconfigToFile is also creating a temporary file referenced by new KUBECONFIG variable setting, 74 | // we need to take care of it's cleanup 75 | tempKubeconfigPath, _ := os.LookupEnv("KUBECONFIG") 76 | defer func() { 77 | logger.Debugln("Cleaning up temporary kubeconfig", tempKubeconfigPath) 78 | err := OsRemove(tempKubeconfigPath) 79 | if err != nil { 80 | fmt.Println(err) 81 | } 82 | }() 83 | 84 | logger.Debugln("Executing command with temporary kubeconfig as backplane-cluster-admin") 85 | ocCmd := ExecCmd("oc", argv[1:]...) 86 | ocCmd.Env = append(ocCmd.Env, os.Environ()...) 87 | ocCmd.Stdin = os.Stdin 88 | ocCmd.Stderr = os.Stderr 89 | ocCmd.Stdout = os.Stdout 90 | err = ocCmd.Run() 91 | if err != nil { 92 | return err 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /pkg/utils/util_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestParseParamFlag(t *testing.T) { 10 | tests := []struct { 11 | inp []string 12 | expect map[string]string 13 | expErr bool 14 | }{ 15 | { 16 | inp: []string{"k1=v1"}, 17 | expect: map[string]string{"k1": "v1"}, 18 | expErr: false, 19 | }, 20 | { 21 | inp: []string{"k1=v1", "k2=v2"}, 22 | expect: map[string]string{"k1": "v1", "k2": "v2"}, 23 | expErr: false, 24 | }, 25 | { 26 | inp: []string{"k1=v1", "k1=v2"}, 27 | expect: map[string]string{"k1": "v2"}, 28 | expErr: false, 29 | }, 30 | { 31 | inp: []string{"k1"}, 32 | expect: nil, 33 | expErr: true, 34 | }, 35 | { 36 | inp: []string{"k1="}, 37 | expect: map[string]string{"k1": ""}, 38 | expErr: false, 39 | }, 40 | } 41 | 42 | for n, tt := range tests { 43 | t.Run(fmt.Sprintf("case %d", n), func(t *testing.T) { 44 | result, err := ParseParamsFlag(tt.inp) 45 | if !reflect.DeepEqual(result, tt.expect) { 46 | t.Errorf("Expecting: %s, but get: %s", tt.expect, result) 47 | } 48 | if tt.expErr && err == nil { 49 | t.Errorf("Expecting error but got none") 50 | } 51 | }) 52 | } 53 | } 54 | 55 | func TestGetFreePort(t *testing.T) { 56 | port, err := GetFreePort() 57 | if err != nil { 58 | t.Errorf("unable get port") 59 | } 60 | if port <= 1024 || port > 65535 { 61 | t.Errorf("unexpected port %d", port) 62 | } 63 | } 64 | 65 | func TestMatchBaseDomain(t *testing.T) { 66 | tests := []struct { 67 | name string 68 | longURL string 69 | baseDomain string 70 | expect bool 71 | }{ 72 | { 73 | name: "case-1", 74 | longURL: "a.example.com", 75 | baseDomain: "example.com", 76 | expect: true, 77 | }, 78 | { 79 | name: "case-2", 80 | longURL: "a.b.c.example.com", 81 | baseDomain: "example.com", 82 | expect: true, 83 | }, 84 | { 85 | name: "case-3", 86 | longURL: "example.com", 87 | baseDomain: "example.com", 88 | expect: true, 89 | }, 90 | { 91 | name: "case-4", 92 | longURL: "a.example.com", 93 | baseDomain: "", 94 | expect: true, 95 | }, 96 | { 97 | name: "case-5", 98 | longURL: "", 99 | baseDomain: "", 100 | expect: true, 101 | }, 102 | { 103 | name: "case-6", 104 | longURL: "", 105 | baseDomain: "example.com", 106 | expect: false, 107 | }, 108 | { 109 | name: "case-7", 110 | longURL: "a.example.com.io", 111 | baseDomain: "example.com", 112 | expect: false, 113 | }, 114 | { 115 | name: "case-8", 116 | longURL: "a.b.c", 117 | baseDomain: "e.f.g", 118 | expect: false, 119 | }, 120 | { 121 | name: "case-9", 122 | longURL: "a", 123 | baseDomain: "a", 124 | expect: true, 125 | }, 126 | } 127 | 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | result := MatchBaseDomain(tt.longURL, tt.baseDomain) 131 | if result != tt.expect { 132 | t.Errorf("Expecting: %t, but get: %t", tt.expect, result) 133 | } 134 | }) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/managedJob/logsManagedJob.go: -------------------------------------------------------------------------------- 1 | package managedjob 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | BackplaneApi "github.com/openshift/backplane-api/pkg/client" 13 | 14 | "github.com/openshift/backplane-cli/pkg/backplaneapi" 15 | "github.com/openshift/backplane-cli/pkg/cli/config" 16 | "github.com/openshift/backplane-cli/pkg/ocm" 17 | "github.com/openshift/backplane-cli/pkg/utils" 18 | ) 19 | 20 | func newLogsManagedJobCmd() *cobra.Command { 21 | cmd := &cobra.Command{ 22 | Use: "logs ", 23 | Short: "Get logs of a managedjob", 24 | SilenceUsage: true, 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | // ======== Parsing Flags ======== 27 | // Cluster ID flag 28 | clusterKey, err := cmd.Flags().GetString("cluster-id") 29 | if err != nil { 30 | return err 31 | } 32 | 33 | // URL flag 34 | urlFlag, err := cmd.Flags().GetString("url") 35 | if err != nil { 36 | return err 37 | } 38 | 39 | // raw flag 40 | rawFlag, err := cmd.Flags().GetBool("raw") 41 | if err != nil { 42 | return err 43 | } 44 | logFlag, err := cmd.Flags().GetBool("follow") 45 | if err != nil { 46 | return err 47 | } 48 | 49 | managerFlag, err := cmd.Flags().GetBool("manager") 50 | if err != nil { 51 | return err 52 | } 53 | 54 | // ======== Parsing Args ======== 55 | if len(args) < 1 { 56 | return fmt.Errorf("missing managedjob name as an argument") 57 | } 58 | managedJobName := args[0] 59 | 60 | // ======== Initialize backplaneURL ======== 61 | bpConfig, err := config.GetBackplaneConfiguration() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | bpCluster, err := utils.DefaultClusterUtils.GetBackplaneCluster(clusterKey) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | if managerFlag { 72 | if mcid, _, _, err := ocm.DefaultOCMInterface.GetManagingCluster(bpCluster.ClusterID); err == nil { 73 | bpCluster, err = utils.DefaultClusterUtils.GetBackplaneCluster(mcid) 74 | if err != nil { 75 | return err 76 | } 77 | } 78 | } 79 | 80 | backplaneHost := bpConfig.URL 81 | if err != nil { 82 | return err 83 | } 84 | clusterID := bpCluster.ClusterID 85 | 86 | if urlFlag != "" { 87 | backplaneHost = urlFlag 88 | } 89 | 90 | client, err := backplaneapi.DefaultClientUtils.MakeRawBackplaneAPIClient(backplaneHost) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | // ======== Call Endpoint ======== 96 | version := "v2" 97 | resp, err := client.GetJobLogs(context.TODO(), clusterID, managedJobName, &BackplaneApi.GetJobLogsParams{Version: &version, Follow: &logFlag}) 98 | 99 | // ======== Render Results ======== 100 | if err != nil { 101 | return err 102 | } 103 | 104 | if resp.StatusCode != http.StatusOK { 105 | return utils.TryPrintAPIError(resp, rawFlag) 106 | } 107 | 108 | _, err = io.Copy(os.Stdout, resp.Body) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | return nil 114 | }, 115 | } 116 | cmd.PersistentFlags().BoolP("follow", "f", false, "Specify if logs should be streamed") 117 | cmd.PersistentFlags().Bool("manager", false, "Fetch the logs directly from the hive/MC") 118 | return cmd 119 | } 120 | -------------------------------------------------------------------------------- /pkg/container/common.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/mitchellh/go-homedir" 10 | "github.com/openshift/backplane-cli/pkg/ocm" 11 | ) 12 | 13 | // fetchPullSecretIfNotExist will check if there's a pull secrect file 14 | // under $HOME/.kube/, if not, it will ask OCM for the pull secrect 15 | // The pull secret is written to a file 16 | func fetchPullSecretIfNotExist() (string, string, error) { 17 | configDirectory, err := GetConfigDirectory() 18 | if err != nil { 19 | return "", "", err 20 | } 21 | 22 | configFilename := filepath.Join(configDirectory, "config.json") 23 | 24 | // Check if file already exists 25 | if _, err = os.Stat(configFilename); !os.IsNotExist(err) { 26 | return configDirectory, configFilename, nil 27 | } 28 | 29 | // If directory doesn't exist, create it with the right permissions 30 | if err := os.MkdirAll(configDirectory, 0700); err != nil { 31 | return "", "", err 32 | } 33 | 34 | response, err := ocm.DefaultOCMInterface.GetPullSecret() 35 | if err != nil { 36 | return "", "", fmt.Errorf("failed to get pull secret from ocm: %v", err) 37 | } 38 | err = os.WriteFile(configFilename, []byte(response), 0600) 39 | if err != nil { 40 | return "", "", fmt.Errorf("failed to write authfile for pull secret: %v", err) 41 | } 42 | 43 | return configDirectory, configFilename, nil 44 | } 45 | 46 | // GetConfigDirectory returns pull secret file saving path 47 | // Defaults to ~/.kube/ocm-pull-secret 48 | func GetConfigDirectory() (string, error) { 49 | if pullSecretConfigDirectory == "" { 50 | home, err := homedir.Dir() 51 | if err != nil { 52 | return "", fmt.Errorf("can't get user homedir. Error: %s", err.Error()) 53 | } 54 | 55 | // Update config directory default path 56 | pullSecretConfigDirectory = filepath.Join(home, ".kube/ocm-pull-secret") 57 | } 58 | 59 | return pullSecretConfigDirectory, nil 60 | } 61 | 62 | // generalStopContainer stops a container using the specified container engine (podman/docker). 63 | // This function is OS independent and works with both podman and docker. 64 | func generalStopContainer(containerEngine string, containerName string) error { 65 | engStopArgs := []string{ 66 | "container", 67 | "stop", 68 | containerName, 69 | } 70 | stopCmd := createCommand(containerEngine, engStopArgs...) 71 | stopCmd.Stderr = os.Stderr 72 | stopCmd.Stdout = nil 73 | 74 | err := stopCmd.Run() 75 | 76 | if err != nil { 77 | return fmt.Errorf("failed to stop container %s: %s", containerName, err) 78 | } 79 | return nil 80 | } 81 | 82 | // generalContainerIsExist checks if a container with the given name exists. 83 | // It uses the container engine's ps command with filters to determine existence. 84 | func generalContainerIsExist(containerEngine string, containerName string) (bool, error) { 85 | var out bytes.Buffer 86 | filter := fmt.Sprintf("name=%s", containerName) 87 | existArgs := []string{ 88 | "ps", 89 | "-aq", 90 | "--filter", 91 | filter, 92 | } 93 | existCmd := createCommand(containerEngine, existArgs...) 94 | existCmd.Stderr = os.Stderr 95 | existCmd.Stdout = &out 96 | 97 | err := existCmd.Run() 98 | 99 | if err != nil { 100 | return false, fmt.Errorf("failed to check container exist %s: %s", containerName, err) 101 | } 102 | if out.String() != "" { 103 | return true, nil 104 | } 105 | return false, nil 106 | } 107 | -------------------------------------------------------------------------------- /pkg/login/printClusterInfo.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/openshift/backplane-cli/pkg/ocm" 7 | logger "github.com/sirupsen/logrus" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | //displayClusterInfo retrieves and displays basic information about the target cluster. 12 | 13 | func PrintClusterInfo(clusterID string) error { 14 | logger := logger.WithField("clusterID", clusterID) 15 | 16 | // Retrieve cluster information 17 | clusterInfo, err := ocm.DefaultOCMInterface.GetClusterInfoByID(clusterID) 18 | if err != nil { 19 | return fmt.Errorf("error retrieving cluster info: %w", err) 20 | } 21 | 22 | // Display cluster information 23 | printClusterField("Cluster ID:", clusterInfo.ID()) 24 | printClusterField("Cluster Name:", clusterInfo.Name()) 25 | printClusterField("Cluster Status:", clusterInfo.State()) 26 | printClusterField("Cluster Region:", clusterInfo.Region().ID()) 27 | printClusterField("Cluster Provider:", clusterInfo.CloudProvider().ID()) 28 | printClusterField("Hypershift Enabled:", clusterInfo.Hypershift().Enabled()) 29 | printClusterField("Version:", clusterInfo.OpenshiftVersion()) 30 | GetLimitedSupportStatus(clusterID) 31 | GetAccessProtectionStatus(clusterID) 32 | 33 | logger.Info("Basic cluster information displayed.") 34 | return nil 35 | } 36 | 37 | // GetAccessProtectionStatus retrieves and displays the access protection status for a cluster. 38 | // It checks if the cluster has access protection enabled (not available for govcloud). 39 | // Returns the status as a string and prints it to stdout. 40 | func GetAccessProtectionStatus(clusterID string) string { 41 | ocmConnection, err := ocm.DefaultOCMInterface.SetupOCMConnection() 42 | if err != nil { 43 | logger.Error("Error setting up OCM connection: ", err) 44 | return "Error setting up OCM connection: " + err.Error() 45 | } 46 | 47 | accessProtectionStatus := "Disabled" 48 | 49 | if !(viper.GetBool("govcloud")) { 50 | enabled, err := ocm.DefaultOCMInterface.IsClusterAccessProtectionEnabled(ocmConnection, clusterID) 51 | if err != nil { 52 | fmt.Println("Error retrieving access protection status: ", err) 53 | return "Error retrieving access protection status: " + err.Error() 54 | } 55 | if enabled { 56 | accessProtectionStatus = "Enabled" 57 | } 58 | } 59 | 60 | fmt.Printf("%-25s %s\n", "Access Protection:", accessProtectionStatus) 61 | 62 | return accessProtectionStatus 63 | } 64 | 65 | // GetLimitedSupportStatus retrieves and displays the limited support status for a cluster. 66 | // It checks the cluster's limited support reason count and displays the appropriate status. 67 | // Returns the count as a string and prints the status to stdout. 68 | func GetLimitedSupportStatus(clusterID string) string { 69 | clusterInfo, err := ocm.DefaultOCMInterface.GetClusterInfoByID(clusterID) 70 | if err != nil { 71 | return "Error retrieving cluster info: " + err.Error() 72 | } 73 | if clusterInfo.Status().LimitedSupportReasonCount() != 0 { 74 | fmt.Printf("%-25s %s", "Limited Support Status: ", "Limited Support\n") 75 | } else { 76 | fmt.Printf("%-25s %s", "Limited Support Status: ", "Fully Supported\n") 77 | } 78 | return fmt.Sprintf("%d", clusterInfo.Status().LimitedSupportReasonCount()) 79 | } 80 | 81 | // printClusterField prints a cluster field with consistent formatting. 82 | // It uses a fixed width for field names to ensure aligned output. 83 | func printClusterField(fieldName string, value interface{}) { 84 | fmt.Printf("%-25s %v\n", fieldName, value) 85 | } 86 | -------------------------------------------------------------------------------- /docs/AUTO_MERGE_SETUP.md: -------------------------------------------------------------------------------- 1 | # Dependabot Auto-Merge Setup 2 | 3 | This repository is configured to automatically merge safe dependency updates from Dependabot once they pass all CI checks. 4 | 5 | ## How It Works 6 | 7 | ### Dependabot Configuration 8 | - **Location**: `.github/dependabot.yml` 9 | - **Ecosystems**: Go modules (`gomod`) and Docker images 10 | - **Schedule**: Weekly updates 11 | - **Labels**: All Dependabot PRs are automatically labeled with `area/dependency` and `ok-to-test` 12 | - **Grouping**: Related dependencies (AWS SDK, Kubernetes, OpenShift) are grouped together to reduce PR volume 13 | 14 | ### Auto-Merge Rules 15 | The auto-merge workflow (`.github/workflows/dependabot-auto-merge.yml`) will automatically merge PRs that meet ALL of the following criteria: 16 | 17 | ✅ **Safe Update Types** (auto-merged): 18 | - **Patch updates** (`1.2.3` → `1.2.4`) - Bug fixes and security patches 19 | - **Minor updates** (`1.2.3` → `1.3.0`) - New features, backward compatible 20 | - **Digest updates** - Docker image digest updates 21 | 22 | ❌ **Requires Manual Review** (NOT auto-merged): 23 | - **Major updates** (`1.2.3` → `2.0.0`) - Potential breaking changes 24 | - PRs missing required labels 25 | - PRs that fail CI checks 26 | 27 | ### Required Labels 28 | For auto-merge to work, Dependabot PRs must have these labels (automatically applied): 29 | - `area/dependency` 30 | - `ok-to-test` 31 | 32 | ## Testing the Setup 33 | 34 | ### Manual Testing 35 | 1. Create a test dependency update PR manually 36 | 2. Verify the auto-merge workflow triggers 37 | 3. Check that CI status checks are required 38 | 4. Confirm auto-merge only works for safe updates 39 | 40 | ### Monitoring Auto-Merge 41 | - Check the "Actions" tab to see workflow runs 42 | - Review auto-merge decisions in workflow logs 43 | - Monitor Dependabot PRs for proper labeling and auto-merge behavior 44 | 45 | ## Troubleshooting 46 | 47 | ### Auto-Merge Not Working 48 | 1. **Check branch protection**: Ensure required status checks are configured 49 | 2. **Verify labels**: Dependabot PRs should have `area/dependency` and `ok-to-test` 50 | 3. **Review permissions**: GitHub Actions needs write permissions 51 | 4. **Check update type**: Only patch/minor/digest updates are auto-merged 52 | 53 | ### CI Failures 54 | 1. **Test failures**: Review test output in CI logs 55 | 2. **Lint failures**: Run `make lint` locally to fix issues 56 | 3. **Build failures**: Ensure code compiles with `make build` 57 | 4. **Security issues**: Review vulnerability scan results 58 | 59 | ### Manual Override 60 | To manually merge a Dependabot PR that wasn't auto-merged: 61 | 1. Review the changes and changelog 62 | 2. Ensure all CI checks pass 63 | 3. Manually approve and merge the PR 64 | 65 | ## Security Considerations 66 | 67 | - **Patch updates** are generally safe and contain security fixes 68 | - **Minor updates** should be backward compatible but may introduce new features 69 | - **Major updates** require manual review due to potential breaking changes 70 | - **Vulnerability scanning** runs on all PRs to catch security issues 71 | - **Branch protection** ensures no code is merged without passing CI 72 | 73 | ## Maintenance 74 | 75 | ### Regular Tasks 76 | - Monitor auto-merge success rate 77 | - Review any manually-merged dependency PRs for patterns 78 | - Update CI workflows as needed 79 | - Adjust Dependabot configuration based on project needs 80 | 81 | ### Updating This Setup 82 | - Modify `.github/dependabot.yml` to change update frequency or add ignores 83 | - Update `.github/workflows/dependabot-auto-merge.yml` to adjust auto-merge rules 84 | - Auto-merge relies on the repository's existing CI infrastructure - no separate CI workflow needed 85 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/managedJob/deleteManagedJob.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Red Hat, Inc 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package managedjob 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | "net/http" 22 | 23 | "github.com/spf13/cobra" 24 | "gopkg.in/AlecAivazis/survey.v1" 25 | 26 | "github.com/openshift/backplane-cli/pkg/backplaneapi" 27 | "github.com/openshift/backplane-cli/pkg/cli/config" 28 | "github.com/openshift/backplane-cli/pkg/utils" 29 | ) 30 | 31 | func newDeleteManagedJobCmd() *cobra.Command { 32 | cmd := &cobra.Command{ 33 | Use: "delete ", 34 | Aliases: []string{"del"}, 35 | Short: "Delete a managed job", 36 | Args: cobra.ExactArgs(1), 37 | SilenceUsage: true, 38 | RunE: func(cmd *cobra.Command, args []string) error { 39 | // ======== Parsing Flags ======== 40 | // Cluster ID flag 41 | clusterKey, err := cmd.Flags().GetString("cluster-id") 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // URL flag 47 | urlFlag, err := cmd.Flags().GetString("url") 48 | if err != nil { 49 | return err 50 | } 51 | 52 | // assume-yes flag 53 | yesFlag, err := cmd.Flags().GetBool("yes") 54 | if err != nil { 55 | return err 56 | } 57 | 58 | // ======== Parsing Args ======== 59 | if len(args) < 1 { 60 | return fmt.Errorf("missing managedjob name as an argument") 61 | } 62 | managedJobName := args[0] 63 | 64 | // ======== Initialize backplaneURL ======== 65 | bpConfig, err := config.GetBackplaneConfiguration() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | bpCluster, err := utils.DefaultClusterUtils.GetBackplaneCluster(clusterKey) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | backplaneHost := bpConfig.URL 76 | if err != nil { 77 | return err 78 | } 79 | clusterID := bpCluster.ClusterID 80 | 81 | if urlFlag != "" { 82 | backplaneHost = urlFlag 83 | } 84 | 85 | // ======== Warn User ======== 86 | if !yesFlag { 87 | confirm := false 88 | prompt := &survey.Confirm{ 89 | Message: "Deleting the job will also delete the logs.\nDo you want to continue?", 90 | } 91 | err := survey.AskOne(prompt, &confirm, nil) 92 | if err != nil { 93 | return err 94 | } 95 | if !confirm { 96 | fmt.Printf("Aborted.\n") 97 | return nil 98 | } 99 | } 100 | 101 | client, err := backplaneapi.DefaultClientUtils.MakeRawBackplaneAPIClient(backplaneHost) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | // ======== Call Endpoint ======== 107 | resp, err := client.DeleteJob(context.TODO(), clusterID, managedJobName) 108 | 109 | // ======== Render Results ======== 110 | if err != nil { 111 | return err 112 | } 113 | 114 | if resp.StatusCode != http.StatusOK { 115 | return utils.TryPrintAPIError(resp, false) 116 | } 117 | 118 | fmt.Printf("Deleted Job ID: %s\n", managedJobName) 119 | return nil 120 | }, 121 | } 122 | cmd.Flags().BoolP("yes", "y", false, "Answer yes to all prompts") 123 | return cmd 124 | } 125 | -------------------------------------------------------------------------------- /pkg/cli/session/mocks/sessionMock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/openshift/backplane-cli/pkg/cli/session (interfaces: BackplaneSessionInterface) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=./pkg/cli/session/mocks/sessionMock.go -package=mocks github.com/openshift/backplane-cli/pkg/cli/session BackplaneSessionInterface 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | cobra "github.com/spf13/cobra" 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockBackplaneSessionInterface is a mock of BackplaneSessionInterface interface. 20 | type MockBackplaneSessionInterface struct { 21 | ctrl *gomock.Controller 22 | recorder *MockBackplaneSessionInterfaceMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockBackplaneSessionInterfaceMockRecorder is the mock recorder for MockBackplaneSessionInterface. 27 | type MockBackplaneSessionInterfaceMockRecorder struct { 28 | mock *MockBackplaneSessionInterface 29 | } 30 | 31 | // NewMockBackplaneSessionInterface creates a new mock instance. 32 | func NewMockBackplaneSessionInterface(ctrl *gomock.Controller) *MockBackplaneSessionInterface { 33 | mock := &MockBackplaneSessionInterface{ctrl: ctrl} 34 | mock.recorder = &MockBackplaneSessionInterfaceMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockBackplaneSessionInterface) EXPECT() *MockBackplaneSessionInterfaceMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // Delete mocks base method. 44 | func (m *MockBackplaneSessionInterface) Delete() error { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "Delete") 47 | ret0, _ := ret[0].(error) 48 | return ret0 49 | } 50 | 51 | // Delete indicates an expected call of Delete. 52 | func (mr *MockBackplaneSessionInterfaceMockRecorder) Delete() *gomock.Call { 53 | mr.mock.ctrl.T.Helper() 54 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockBackplaneSessionInterface)(nil).Delete)) 55 | } 56 | 57 | // RunCommand mocks base method. 58 | func (m *MockBackplaneSessionInterface) RunCommand(cmd *cobra.Command, args []string) error { 59 | m.ctrl.T.Helper() 60 | ret := m.ctrl.Call(m, "RunCommand", cmd, args) 61 | ret0, _ := ret[0].(error) 62 | return ret0 63 | } 64 | 65 | // RunCommand indicates an expected call of RunCommand. 66 | func (mr *MockBackplaneSessionInterfaceMockRecorder) RunCommand(cmd, args any) *gomock.Call { 67 | mr.mock.ctrl.T.Helper() 68 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunCommand", reflect.TypeOf((*MockBackplaneSessionInterface)(nil).RunCommand), cmd, args) 69 | } 70 | 71 | // Setup mocks base method. 72 | func (m *MockBackplaneSessionInterface) Setup() error { 73 | m.ctrl.T.Helper() 74 | ret := m.ctrl.Call(m, "Setup") 75 | ret0, _ := ret[0].(error) 76 | return ret0 77 | } 78 | 79 | // Setup indicates an expected call of Setup. 80 | func (mr *MockBackplaneSessionInterfaceMockRecorder) Setup() *gomock.Call { 81 | mr.mock.ctrl.T.Helper() 82 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Setup", reflect.TypeOf((*MockBackplaneSessionInterface)(nil).Setup)) 83 | } 84 | 85 | // Start mocks base method. 86 | func (m *MockBackplaneSessionInterface) Start() error { 87 | m.ctrl.T.Helper() 88 | ret := m.ctrl.Call(m, "Start") 89 | ret0, _ := ret[0].(error) 90 | return ret0 91 | } 92 | 93 | // Start indicates an expected call of Start. 94 | func (mr *MockBackplaneSessionInterfaceMockRecorder) Start() *gomock.Call { 95 | mr.mock.ctrl.T.Helper() 96 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockBackplaneSessionInterface)(nil).Start)) 97 | } 98 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/mcp/mcp.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/modelcontextprotocol/go-sdk/mcp" 12 | mcptools "github.com/openshift/backplane-cli/pkg/ai/mcp" 13 | ) 14 | 15 | // MCPCmd represents the mcp command 16 | var MCPCmd = &cobra.Command{ 17 | Use: "mcp", 18 | Short: "Start Model Context Protocol server", 19 | Long: `Start a Model Context Protocol (MCP) server that provides access to backplane resources and functionality. 20 | 21 | The MCP server allows AI assistants to interact with backplane clusters, retrieve status information, 22 | and perform operations through the Model Context Protocol standard.`, 23 | Args: cobra.ExactArgs(0), 24 | RunE: runMCP, 25 | SilenceUsage: true, 26 | } 27 | 28 | func init() { 29 | MCPCmd.Flags().Bool("http", false, "Run MCP server over HTTP instead of stdio") 30 | MCPCmd.Flags().Int("port", 8080, "Port to run HTTP server on (only used with --http)") 31 | } 32 | 33 | func runMCP(cmd *cobra.Command, argv []string) error { 34 | // Get flag values 35 | useHTTP, _ := cmd.Flags().GetBool("http") 36 | port, _ := cmd.Flags().GetInt("port") 37 | 38 | // Create a server with backplane tools. 39 | server := mcp.NewServer(&mcp.Implementation{Name: "backplane", Version: "v1.0.0"}, nil) 40 | 41 | // Add the info tool 42 | mcp.AddTool(server, &mcp.Tool{ 43 | Name: "info", 44 | Description: "Get information about the current backplane CLI installation, configuration", 45 | }, mcptools.GetBackplaneInfo) 46 | 47 | // Add the login tool 48 | mcp.AddTool(server, &mcp.Tool{ 49 | Name: "login", 50 | Description: "Login to cluster via backplane", 51 | }, mcptools.BackplaneLogin) 52 | 53 | // Add the console tool 54 | mcp.AddTool(server, &mcp.Tool{ 55 | Name: "console", 56 | Description: "Start OpenShift web console for a cluster. Automatically opens in browser. Console runs in background.", 57 | }, mcptools.BackplaneConsole) 58 | 59 | // Add the cluster resource tool 60 | mcp.AddTool(server, &mcp.Tool{ 61 | Name: "cluster-resource", 62 | Description: "Execute read-only Kubernetes resource operations (get, describe, logs, top, explain) on cluster resources", 63 | }, mcptools.BackplaneClusterResource) 64 | 65 | // Add the cloud console tool 66 | mcp.AddTool(server, &mcp.Tool{ 67 | Name: "cloud-console", 68 | Description: "Get cloud provider console access for a cluster. Automatically opens in browser with temporary credentials. Runs in background.", 69 | }, mcptools.BackplaneCloudConsole) 70 | 71 | // Choose transport method based on flags 72 | if useHTTP { 73 | // Run the server over HTTP using StreamableHTTPHandler 74 | addr := fmt.Sprintf(":%d", port) 75 | fmt.Printf("Starting MCP server on HTTP at http://localhost%s\n", addr) 76 | 77 | // Create HTTP handler that returns our server 78 | handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { 79 | return server 80 | }, nil) 81 | 82 | httpServer := &http.Server{ 83 | Addr: addr, 84 | Handler: handler, 85 | ReadHeaderTimeout: 10 * time.Second, 86 | ReadTimeout: 30 * time.Second, 87 | WriteTimeout: 30 * time.Second, 88 | IdleTimeout: 60 * time.Second, 89 | } 90 | 91 | if err := httpServer.ListenAndServe(); err != nil { 92 | return fmt.Errorf("HTTP server error: %w", err) 93 | } 94 | } else { 95 | // Run the server over stdin/stdout, until the client disconnects. 96 | if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { 97 | return err 98 | } 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/logout/logout.go: -------------------------------------------------------------------------------- 1 | package logout 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | logger "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | "k8s.io/client-go/tools/clientcmd" 10 | 11 | "github.com/openshift/backplane-cli/pkg/login" 12 | "github.com/openshift/backplane-cli/pkg/ocm" 13 | "github.com/openshift/backplane-cli/pkg/utils" 14 | ) 15 | 16 | // LogoutCmd represents the logout command 17 | var LogoutCmd = &cobra.Command{ 18 | Use: "logout", 19 | Short: "Logout of the current cluster by deleting the related reference in kubeconfig", 20 | Long: `Logout command will remove the current kubeconfig context and 21 | remove the reference to the current cluster if you have logged on 22 | with backplane`, 23 | Example: "ocm backplane logout", 24 | RunE: runLogout, 25 | SilenceUsage: true, 26 | } 27 | 28 | func runLogout(cmd *cobra.Command, argv []string) error { 29 | 30 | // Logout specific cluster 31 | if len(argv) == 1 { 32 | clusterID, _, err := ocm.DefaultOCMInterface.GetTargetCluster(argv[0]) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | // Remove cluster specific Kubeconfig file 38 | err = login.RemoveClusterKubeConfig(clusterID) 39 | if err != nil { 40 | return err 41 | } 42 | fmt.Printf("Logged out from backplane: %s\n", argv[0]) 43 | } else { 44 | // Get raw kubeconfig from the default kubeconfig file 45 | rc, err := utils.ReadKubeconfigRaw() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | // Kubeconfig has three main objects: Cluster/Context/User 51 | // Context is the current Cluster/User combination Kubeconfig is 52 | // currently working on 53 | 54 | // To cleanup, we use `CurrentContext` to obtain the cluster and user 55 | // and delete all relevant info 56 | currentContextObj := rc.Contexts[rc.CurrentContext] 57 | if currentContextObj == nil { 58 | return fmt.Errorf("current context does not exist, skipping") 59 | } 60 | currentUser := currentContextObj.AuthInfo 61 | currentCluster := currentContextObj.Cluster 62 | currentClusterObj := rc.Clusters[currentCluster] 63 | if currentClusterObj == nil { 64 | return fmt.Errorf("current cluster not found, skipping") 65 | } 66 | currentServer := currentClusterObj.Server 67 | 68 | // backplane should only handle `logout` associated context 69 | // created with backplane itself, we check this via matching 70 | // the cluster server endpoint 71 | backplaneServerRegex := regexp.MustCompile(utils.BackplaneAPIURLRegexp) 72 | 73 | logger.WithFields(logger.Fields{ 74 | "currentServer": currentServer, 75 | "currentUser": currentUser, 76 | "currentContext": rc.CurrentContext, 77 | }).Debugln("Current context") 78 | 79 | if !backplaneServerRegex.MatchString(currentServer) { 80 | return fmt.Errorf("you're not logged in using backplane, skipping") 81 | } 82 | 83 | logger.Debugln("Logging out of the current cluster") 84 | 85 | // Delete the current cluster/context/user and set current-context to empty str 86 | delete(rc.Clusters, currentCluster) 87 | delete(rc.Contexts, rc.CurrentContext) 88 | delete(rc.AuthInfos, currentUser) 89 | savedContext := rc.CurrentContext 90 | // Setting current-context to empty str will make `oc` command return 91 | // errors saying that the config is incomplete, however, this is inline with 92 | // the behavior of `oc config unset current-context` 93 | rc.CurrentContext = "" 94 | 95 | pathOptions := clientcmd.NewDefaultPathOptions() 96 | err = clientcmd.ModifyConfig(pathOptions, rc, true) 97 | if err != nil { 98 | return err 99 | } 100 | logger.Debugln("Kubeconfig written") 101 | fmt.Printf("Logged out from backplane: %s\n", savedContext) 102 | } 103 | 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 Red Hat, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "os" 21 | 22 | log "github.com/sirupsen/logrus" 23 | "github.com/spf13/cobra" 24 | 25 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/accessrequest" 26 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/cloud" 27 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/config" 28 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/console" 29 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/elevate" 30 | healthcheck "github.com/openshift/backplane-cli/cmd/ocm-backplane/healthcheck" 31 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/login" 32 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/logout" 33 | managedjob "github.com/openshift/backplane-cli/cmd/ocm-backplane/managedJob" 34 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/mcp" 35 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/monitoring" 36 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/remediation" 37 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/script" 38 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/session" 39 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/status" 40 | testjob "github.com/openshift/backplane-cli/cmd/ocm-backplane/testJob" 41 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/upgrade" 42 | "github.com/openshift/backplane-cli/cmd/ocm-backplane/version" 43 | "github.com/openshift/backplane-cli/pkg/cli/globalflags" 44 | ) 45 | 46 | // rootCmd represents the base command when called without any subcommands 47 | var rootCmd = &cobra.Command{ 48 | Use: "ocm-backplane", 49 | Short: "backplane plugin for OCM", 50 | Long: `This is a binary for backplane plugin. 51 | The current function ocm-backplane provides is to login a cluster, 52 | which get a proxy url from backplane for the target cluster. 53 | After login, users can use oc command to operate the target cluster`, 54 | SilenceErrors: true, 55 | } 56 | 57 | // Execute adds all child commands to the root command and sets flags appropriately. 58 | // This is called by main.main(). It only needs to happen once to the rootCmd. 59 | func Execute() { 60 | if err := rootCmd.Execute(); err != nil { 61 | log.Errorln(err.Error()) 62 | os.Exit(1) 63 | } 64 | } 65 | 66 | func init() { 67 | // Add Verbosity flag for all commands 68 | globalflags.AddVerbosityFlag(rootCmd) 69 | 70 | // Register sub-commands 71 | rootCmd.AddCommand(accessrequest.NewAccessRequestCmd()) 72 | rootCmd.AddCommand(console.NewConsoleCmd()) 73 | rootCmd.AddCommand(config.NewConfigCmd()) 74 | rootCmd.AddCommand(cloud.CloudCmd) 75 | rootCmd.AddCommand(elevate.ElevateCmd) 76 | rootCmd.AddCommand(login.LoginCmd) 77 | rootCmd.AddCommand(logout.LogoutCmd) 78 | rootCmd.AddCommand(managedjob.NewManagedJobCmd()) 79 | rootCmd.AddCommand(mcp.MCPCmd) 80 | rootCmd.AddCommand(script.NewScriptCmd()) 81 | rootCmd.AddCommand(status.StatusCmd) 82 | rootCmd.AddCommand(session.NewCmdSession()) 83 | rootCmd.AddCommand(testjob.NewTestJobCommand()) 84 | rootCmd.AddCommand(upgrade.UpgradeCmd) 85 | rootCmd.AddCommand(version.VersionCmd) 86 | rootCmd.AddCommand(monitoring.MonitoringCmd) 87 | rootCmd.AddCommand(healthcheck.HealthCheckCmd) 88 | rootCmd.AddCommand(remediation.NewRemediationCmd()) 89 | } 90 | -------------------------------------------------------------------------------- /pkg/jira/issueService.go: -------------------------------------------------------------------------------- 1 | package jira 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/openshift/backplane-cli/pkg/cli/config" 8 | 9 | "github.com/andygrunwald/go-jira" 10 | ) 11 | 12 | type IssueServiceInterface interface { 13 | Create(issue *jira.Issue) (*jira.Issue, *jira.Response, error) 14 | Get(issueID string, options *jira.GetQueryOptions) (*jira.Issue, *jira.Response, error) 15 | Update(issue *jira.Issue) (*jira.Issue, *jira.Response, error) 16 | GetTransitions(id string) ([]jira.Transition, *jira.Response, error) 17 | DoTransition(ticketID, transitionID string) (*jira.Response, error) 18 | } 19 | 20 | type IssueServiceGetter interface { 21 | GetIssueService() (*jira.IssueService, error) 22 | } 23 | 24 | type IssueServiceDecorator struct { 25 | Getter IssueServiceGetter 26 | } 27 | 28 | func (decorator *IssueServiceDecorator) Create(issue *jira.Issue) (*jira.Issue, *jira.Response, error) { 29 | issueService, err := decorator.Getter.GetIssueService() 30 | 31 | if err != nil { 32 | return nil, nil, err 33 | } 34 | 35 | return issueService.Create(issue) 36 | } 37 | 38 | func (decorator *IssueServiceDecorator) Get(issueID string, options *jira.GetQueryOptions) (*jira.Issue, *jira.Response, error) { 39 | issueService, err := decorator.Getter.GetIssueService() 40 | 41 | if err != nil { 42 | return nil, nil, err 43 | } 44 | 45 | return issueService.Get(issueID, options) 46 | } 47 | 48 | func (decorator *IssueServiceDecorator) Update(issue *jira.Issue) (*jira.Issue, *jira.Response, error) { 49 | issueService, err := decorator.Getter.GetIssueService() 50 | 51 | if err != nil { 52 | return nil, nil, err 53 | } 54 | 55 | return issueService.Update(issue) 56 | } 57 | 58 | func (decorator *IssueServiceDecorator) GetTransitions(id string) ([]jira.Transition, *jira.Response, error) { 59 | issueService, err := decorator.Getter.GetIssueService() 60 | 61 | if err != nil { 62 | return []jira.Transition{}, nil, err 63 | } 64 | 65 | return issueService.GetTransitions(id) 66 | } 67 | 68 | func (decorator *IssueServiceDecorator) DoTransition(ticketID, transitionID string) (*jira.Response, error) { 69 | issueService, err := decorator.Getter.GetIssueService() 70 | 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return issueService.DoTransition(ticketID, transitionID) 76 | } 77 | 78 | type DefaultIssueServiceGetterImpl struct { 79 | issueService *jira.IssueService 80 | } 81 | 82 | func createIssueService() (*jira.IssueService, error) { 83 | bpConfig, err := config.GetBackplaneConfiguration() 84 | if err != nil { 85 | return nil, fmt.Errorf("failed to load backplane config: %v", err) 86 | } 87 | 88 | if bpConfig.JiraToken == "" { 89 | return nil, fmt.Errorf("JIRA token is not defined, consider defining it running 'ocm-backplane config set %s '", config.JiraTokenViperKey) 90 | } 91 | 92 | transport := jira.PATAuthTransport{ 93 | Token: bpConfig.JiraToken, 94 | } 95 | 96 | jiraClient, err := jira.NewClient(transport.Client(), bpConfig.JiraBaseURL) 97 | 98 | if err != nil || jiraClient == nil { 99 | return nil, fmt.Errorf("failed to create the JIRA client: %v", err) 100 | } 101 | 102 | issueService := jiraClient.Issue 103 | 104 | if issueService == nil { 105 | return nil, errors.New("no issue service in the JIRA client") 106 | } 107 | 108 | return issueService, nil 109 | } 110 | 111 | func (getter *DefaultIssueServiceGetterImpl) GetIssueService() (*jira.IssueService, error) { 112 | if getter.issueService == nil { 113 | issueService, err := createIssueService() 114 | 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | getter.issueService = issueService 120 | } 121 | 122 | return getter.issueService, nil 123 | } 124 | 125 | var DefaultIssueService IssueServiceInterface = &IssueServiceDecorator{Getter: &DefaultIssueServiceGetterImpl{}} 126 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This is for CI test and should build on x86_64 environment 2 | 3 | FROM registry.access.redhat.com/ubi9:9.7 as base 4 | 5 | ### Pre-install dependencies 6 | # These packages will end up in the final image 7 | # Installed here to save build time 8 | RUN yum --assumeyes install \ 9 | jq \ 10 | && yum clean all; 11 | 12 | ### Build backplane-cli 13 | FROM brew.registry.redhat.io/rh-osbs/openshift-golang-builder:rhel_9_golang_1.24 as bp-cli-builder 14 | 15 | 16 | # Configure the env 17 | 18 | RUN go env -w GOTOOLCHAIN=go1.24.4+auto 19 | 20 | #Environment variables 21 | ENV GOOS=linux GO111MODULE=on GOPROXY=https://proxy.golang.org 22 | ENV GOBIN=/gobin GOPATH=/usr/src/go CGO_ENABLED=0 23 | ENV GOTOOLCHAIN=go1.24.4+auto 24 | 25 | # Directory for the binary 26 | RUN mkdir /out 27 | 28 | # Build ocm-backplane from latest 29 | COPY . /ocm-backplane 30 | WORKDIR /ocm-backplane 31 | 32 | RUN make build-static 33 | RUN cp ./ocm-backplane /out 34 | 35 | RUN chmod -R +x /out 36 | 37 | ### Build dependencies 38 | FROM brew.registry.redhat.io/rh-osbs/openshift-golang-builder:rhel_9_golang_1.24 as dep-builder 39 | 40 | # Ensure we can use Go 1.24.4 41 | ENV GOTOOLCHAIN=go1.24.4+auto 42 | 43 | ARG GITHUB_URL="https://api.github.com" 44 | ARG GITHUB_TOKEN="" 45 | 46 | # Replace version with a version number to pin a specific version (eg: "4.7.8") 47 | ARG OC_VERSION="stable-4.16" 48 | ENV OC_URL="https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/${OC_VERSION}" 49 | 50 | # Replace "/latest" with "/tags/{tag}" to pin to a specific version (eg: "/tags/v0.4.0") 51 | ARG OCM_VERSION="latest" 52 | ENV OCM_URL="${GITHUB_URL}/repos/openshift-online/ocm-cli/releases/${OCM_VERSION}" 53 | 54 | # Directory for the extracted binaries, etc 55 | RUN mkdir /out 56 | 57 | # Install the latest OC Binary from the mirror 58 | RUN mkdir /oc 59 | WORKDIR /oc 60 | 61 | # Download jq packages 62 | RUN curl -sSLo /usr/bin/jq https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64 && chmod +x /usr/bin/jq 63 | 64 | # Download the checksum 65 | RUN curl -sSLf ${OC_URL}/sha256sum.txt -o sha256sum.txt 66 | # Download the amd64 binary tarball 67 | RUN FILENAME=$(awk '/openshift-client-linux.*tar\.gz/ && /amd64/ {print $2; exit}' sha256sum.txt) && curl -sSLf -O ${OC_URL}/${FILENAME} 68 | # Check the tarball and checksum match 69 | RUN sha256sum --check --ignore-missing sha256sum.txt 70 | RUN tar --extract --gunzip --no-same-owner --directory /out oc --file *.tar.gz 71 | 72 | # Install ocm 73 | # ocm is not in a tarball 74 | RUN mkdir /ocm 75 | WORKDIR /ocm 76 | 77 | RUN if [[ -n ${GITHUB_TOKEN} ]]; then \ 78 | echo "Authorization: Bearer ${GITHUB_TOKEN}" > auth.txt; \ 79 | else \ 80 | touch auth.txt; \ 81 | fi 82 | 83 | # Download the checksum 84 | RUN curl -H @auth.txt -sSLf $(curl -H @auth.txt -sSLf ${OCM_URL} -o - | jq -r '.assets[] | select(.name|test("linux-amd64.sha256")) | .browser_download_url') -o sha256sum.txt 85 | # Download the binary 86 | RUN curl -H @auth.txt -sSLf -O $(curl -H @auth.txt -sSLf ${OCM_URL} -o - | jq -r '.assets[] | select(.name|test("linux-amd64$")) | .browser_download_url') 87 | # Check the binary and checksum match 88 | RUN sha256sum --check --ignore-missing sha256sum.txt 89 | RUN cp ocm* /out/ocm 90 | 91 | # Make binaries executable 92 | RUN chmod -R +x /out 93 | 94 | ### Build the final image 95 | # This is based on the first image build, with the packages installed 96 | FROM base 97 | 98 | # Copy previously acquired binaries into the $PATH 99 | ENV BIN_DIR="/usr/local/bin" 100 | COPY --from=dep-builder /out/oc ${BIN_DIR} 101 | COPY --from=dep-builder /out/ocm ${BIN_DIR} 102 | COPY --from=bp-cli-builder /out/ocm-backplane ${BIN_DIR} 103 | 104 | # Validate 105 | RUN oc completion bash > /etc/bash_completion.d/oc 106 | RUN ocm completion > /etc/bash_completion.d/ocm 107 | 108 | ENV HOME="/home" 109 | RUN chmod a+w -R ${HOME} 110 | WORKDIR ${HOME} 111 | 112 | ENTRYPOINT ["/bin/bash"] 113 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/config/set.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | 8 | "strconv" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | "golang.org/x/term" 13 | "gopkg.in/AlecAivazis/survey.v1" 14 | 15 | "github.com/openshift/backplane-cli/pkg/cli/config" 16 | ) 17 | 18 | func newSetCmd() *cobra.Command { 19 | cmd := &cobra.Command{ 20 | Use: "set", 21 | Short: "Set Backplane CLI configuration variables", 22 | Example: "ocm backplane config set url https://example.com", 23 | SilenceUsage: true, 24 | Args: cobra.ExactArgs(2), 25 | RunE: setConfig, 26 | } 27 | 28 | return cmd 29 | } 30 | 31 | func setConfig(cmd *cobra.Command, args []string) error { 32 | bpConfig := &config.BackplaneConfiguration{} 33 | 34 | // Retrieve default Backplane CLI config path, $HOME/.config/backplane/config.json 35 | configPath, err := config.GetConfigFilePath() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | if _, err = os.Stat(configPath); err == nil { 41 | viper.SetConfigFile(configPath) 42 | if err := viper.ReadInConfig(); err != nil { 43 | return err 44 | } 45 | 46 | bpConfig.URL = viper.GetString("url") 47 | proxyURL := viper.GetString("proxy-url") 48 | if proxyURL != "" { 49 | bpConfig.ProxyURL = &proxyURL 50 | } 51 | 52 | pagerDutyAPIKey := viper.GetString("pd-key") 53 | if pagerDutyAPIKey != "" { 54 | bpConfig.PagerDutyAPIKey = pagerDutyAPIKey 55 | } 56 | 57 | if (viper.GetBool("govcloud")) { 58 | bpConfig.Govcloud = true 59 | } else { 60 | bpConfig.Govcloud = false 61 | } 62 | bpConfig.SessionDirectory = viper.GetString("session-dir") 63 | bpConfig.JiraToken = viper.GetString(config.JiraTokenViperKey) 64 | } 65 | 66 | // create config directory if it doesn't exist 67 | if dir, err := os.Stat(path.Dir(configPath)); os.IsNotExist(err) || !dir.IsDir() { 68 | // check if stdout is a terminal. if so, prompt user to create config directory 69 | if term.IsTerminal(int(os.Stdout.Fd())) { 70 | confirm := false 71 | prompt := &survey.Confirm{ 72 | Message: fmt.Sprintf("Config directory \"%s\" does not exist. Create it?", path.Dir(configPath)), 73 | Default: true, 74 | } 75 | if err := survey.AskOne(prompt, &confirm, nil); err != nil { 76 | return err 77 | } 78 | if confirm { 79 | if err := os.MkdirAll(path.Dir(configPath), 0750); err != nil { 80 | return err 81 | } 82 | } else { 83 | fmt.Println("Aborted") 84 | return nil 85 | } 86 | } else { 87 | // if we aren't in a terminal, just return an error 88 | return fmt.Errorf("config directory does not exist: %s", path.Dir(configPath)) 89 | } 90 | } 91 | 92 | switch args[0] { 93 | case URLConfigVar: 94 | bpConfig.URL = args[1] 95 | case ProxyURLConfigVar: 96 | bpConfig.ProxyURL = &args[1] 97 | case SessionConfigVar: 98 | bpConfig.SessionDirectory = args[1] 99 | case PagerDutyAPIConfigVar: 100 | bpConfig.PagerDutyAPIKey = args[1] 101 | case config.JiraTokenViperKey: 102 | bpConfig.JiraToken = args[1] 103 | case GovcloudVar: 104 | bpConfig.Govcloud, err = strconv.ParseBool(args[1]) 105 | if err != nil { 106 | return fmt.Errorf("invalid value for %s: %v", GovcloudVar, err) 107 | } 108 | default: 109 | return fmt.Errorf("supported config variables are %s, %s, %s, %s, %s & %s", URLConfigVar, ProxyURLConfigVar, SessionConfigVar, PagerDutyAPIConfigVar, config.JiraTokenViperKey, GovcloudVar) 110 | } 111 | 112 | viper.SetConfigType("json") 113 | viper.Set(URLConfigVar, bpConfig.URL) 114 | viper.Set(ProxyURLConfigVar, bpConfig.ProxyURL) 115 | viper.Set(SessionConfigVar, bpConfig.SessionDirectory) 116 | viper.Set(PagerDutyAPIConfigVar, bpConfig.PagerDutyAPIKey) 117 | viper.Set(config.JiraTokenViperKey, bpConfig.JiraToken) 118 | viper.Set(GovcloudVar, bpConfig.Govcloud) 119 | 120 | err = viper.WriteConfigAs(configPath) 121 | if err != nil { 122 | return err 123 | } 124 | fmt.Println("Configuration file updated at " + configPath) 125 | 126 | return nil 127 | } -------------------------------------------------------------------------------- /pkg/utils/cluster.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "regexp" 7 | 8 | logger "github.com/sirupsen/logrus" 9 | "k8s.io/client-go/tools/clientcmd" 10 | 11 | "github.com/openshift/backplane-cli/pkg/cli/config" 12 | "github.com/openshift/backplane-cli/pkg/ocm" 13 | ) 14 | 15 | type BackplaneCluster struct { 16 | ClusterID string 17 | ClusterURL string // for e.g. https://api-backplane.apps.com/backplane/cluster// 18 | BackplaneHost string // for e.g. https://api-backplane.apps.com 19 | } 20 | 21 | type ClusterUtils interface { 22 | GetClusterIDAndHostFromClusterURL(clusterURL string) (string, string, error) 23 | GetBackplaneClusterFromConfig() (BackplaneCluster, error) 24 | GetBackplaneClusterFromClusterKey(clusterKey string) (BackplaneCluster, error) 25 | GetBackplaneCluster(params ...string) (BackplaneCluster, error) 26 | } 27 | 28 | type DefaultClusterUtilsImpl struct{} 29 | 30 | var ( 31 | DefaultClusterUtils ClusterUtils = &DefaultClusterUtilsImpl{} 32 | ) 33 | 34 | // GetClusterIDAndHostFromClusterURL with Cluster URL format: https://api-backplane.apps.com/backplane/cluster// 35 | func (s *DefaultClusterUtilsImpl) GetClusterIDAndHostFromClusterURL(clusterURL string) (string, string, error) { 36 | parsedURL, err := url.Parse(clusterURL) 37 | if err != nil { 38 | return "", "", err 39 | } 40 | backplaneHost := "https://" + parsedURL.Host 41 | re := regexp.MustCompile(ClusterIDRegexp) 42 | matches := re.FindStringSubmatch(parsedURL.Path) 43 | 44 | if len(matches) < 2 { 45 | return "", backplaneHost, fmt.Errorf("couldn't find cluster-id from the backplane cluster url") 46 | } 47 | clusterID := matches[1] // first capturing group 48 | return clusterID, backplaneHost, nil 49 | } 50 | 51 | // GetBackplaneClusterFromConfig get the backplane cluster from config file 52 | func (s *DefaultClusterUtilsImpl) GetBackplaneClusterFromConfig() (BackplaneCluster, error) { 53 | logger.Debugln("Finding target cluster from kube config") 54 | cfg, err := clientcmd.BuildConfigFromFlags("", clientcmd.NewDefaultPathOptions().GetDefaultFilename()) 55 | if err != nil { 56 | return BackplaneCluster{}, err 57 | } 58 | 59 | clusterID, backplaneHost, err := s.GetClusterIDAndHostFromClusterURL(cfg.Host) 60 | if err != nil { 61 | return BackplaneCluster{}, err 62 | } 63 | cluster := BackplaneCluster{ 64 | ClusterID: clusterID, 65 | BackplaneHost: backplaneHost, 66 | ClusterURL: cfg.Host, 67 | } 68 | logger.WithFields(logger.Fields{ 69 | "ClusterID": cluster.ClusterID, 70 | "BackplaneHost": cluster.BackplaneHost, 71 | "ClusterURL": cluster.ClusterURL}).Debugln("Found target cluster") 72 | return cluster, nil 73 | } 74 | 75 | // GetBackplaneClusterFromClusterKey get the backplane cluster from the given cluster 76 | func (s *DefaultClusterUtilsImpl) GetBackplaneClusterFromClusterKey(clusterKey string) (BackplaneCluster, error) { 77 | logger.WithField("SearchKey", clusterKey).Debugln("Finding target cluster") 78 | clusterID, clusterName, err := ocm.DefaultOCMInterface.GetTargetCluster(clusterKey) 79 | 80 | if err != nil { 81 | return BackplaneCluster{}, err 82 | } 83 | 84 | bpConfig, err := config.GetBackplaneConfiguration() 85 | 86 | backplaneURL := bpConfig.URL 87 | 88 | if err != nil { 89 | return BackplaneCluster{}, err 90 | } 91 | cluster := BackplaneCluster{ 92 | ClusterID: clusterID, 93 | BackplaneHost: backplaneURL, 94 | ClusterURL: fmt.Sprintf("%s/backplane/cluster/%s", backplaneURL, clusterID), 95 | } 96 | logger.WithFields(logger.Fields{ 97 | "ClusterID": cluster.ClusterID, 98 | "BackplaneHost": cluster.BackplaneHost, 99 | "ClusterURL": cluster.ClusterURL, 100 | "Name": clusterName}).Debugln("Found target cluster") 101 | return cluster, nil 102 | } 103 | 104 | // GetBackplaneCluster returns BackplaneCluster, if clusterKey is present it will try to search for cluster otherwise it will load cluster from the kube config file. 105 | func (s *DefaultClusterUtilsImpl) GetBackplaneCluster(params ...string) (BackplaneCluster, error) { 106 | if len(params) > 0 && params[0] != "" { 107 | return s.GetBackplaneClusterFromClusterKey(params[0]) 108 | } 109 | return s.GetBackplaneClusterFromConfig() 110 | } 111 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Auto-Merge 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, ready_for_review] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | checks: read 11 | metadata: read 12 | actions: read 13 | 14 | jobs: 15 | auto-merge: 16 | runs-on: ubuntu-latest 17 | # Only run for Dependabot PRs 18 | if: github.actor == 'dependabot[bot]' 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Fetch Dependabot Metadata 24 | id: metadata 25 | uses: dependabot/fetch-metadata@v2 26 | with: 27 | github-token: "${{ secrets.GITHUB_TOKEN }}" 28 | 29 | - name: Check PR Labels 30 | id: check-labels 31 | run: | 32 | # Check if PR has the required labels for auto-merge 33 | if [[ "${{ contains(github.event.pull_request.labels.*.name, 'area/dependency') }}" == "true" ]] && \ 34 | [[ "${{ contains(github.event.pull_request.labels.*.name, 'ok-to-test') }}" == "true" ]]; then 35 | echo "has-required-labels=true" >> $GITHUB_OUTPUT 36 | else 37 | echo "has-required-labels=false" >> $GITHUB_OUTPUT 38 | fi 39 | 40 | - name: Enable Auto-Merge for Safe Updates 41 | if: | 42 | steps.check-labels.outputs.has-required-labels == 'true' && ( 43 | steps.metadata.outputs.update-type == 'version-update:semver-patch' || 44 | steps.metadata.outputs.update-type == 'version-update:semver-minor' || 45 | steps.metadata.outputs.update-type == 'version-update:semver-digest' 46 | ) 47 | run: | 48 | echo "Enabling auto-merge for ${{ steps.metadata.outputs.update-type }} update" 49 | echo "Dependency: ${{ steps.metadata.outputs.dependency-names }}" 50 | echo "Previous version: ${{ steps.metadata.outputs.previous-version }}" 51 | echo "New version: ${{ steps.metadata.outputs.new-version }}" 52 | 53 | # Enable auto-merge with merge commit strategy 54 | gh pr merge --auto --merge "${{ github.event.pull_request.number }}" 55 | env: 56 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | 58 | - name: Comment on Major Version Updates 59 | if: | 60 | steps.check-labels.outputs.has-required-labels == 'true' && 61 | steps.metadata.outputs.update-type == 'version-update:semver-major' 62 | run: | 63 | gh pr comment "${{ github.event.pull_request.number }}" --body \ 64 | "🚨 **Major Version Update Detected** 🚨 65 | 66 | This PR contains a major version update that requires manual review: 67 | - **Dependency:** ${{ steps.metadata.outputs.dependency-names }} 68 | - **Previous version:** ${{ steps.metadata.outputs.previous-version }} 69 | - **New version:** ${{ steps.metadata.outputs.new-version }} 70 | 71 | Please review the changelog and breaking changes before merging. 72 | 73 | Auto-merge has been **disabled** for this PR." 74 | env: 75 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | 77 | - name: Log Auto-Merge Decision 78 | run: | 79 | echo "Auto-merge decision for PR #${{ github.event.pull_request.number }}:" 80 | echo "- Update type: ${{ steps.metadata.outputs.update-type }}" 81 | echo "- Has required labels: ${{ steps.check-labels.outputs.has-required-labels }}" 82 | echo "- Dependency: ${{ steps.metadata.outputs.dependency-names }}" 83 | 84 | if [[ "${{ steps.metadata.outputs.update-type }}" == "version-update:semver-patch" ]] || \ 85 | [[ "${{ steps.metadata.outputs.update-type }}" == "version-update:semver-minor" ]] || \ 86 | [[ "${{ steps.metadata.outputs.update-type }}" == "version-update:semver-digest" ]]; then 87 | if [[ "${{ steps.check-labels.outputs.has-required-labels }}" == "true" ]]; then 88 | echo "✅ Auto-merge ENABLED" 89 | else 90 | echo "❌ Auto-merge DISABLED: Missing required labels" 91 | fi 92 | else 93 | echo "❌ Auto-merge DISABLED: Major version update or unknown update type" 94 | fi 95 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/managedJob/getManagedJob.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Red Hat, Inc 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package managedjob 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | "net/http" 22 | 23 | "github.com/spf13/cobra" 24 | 25 | BackplaneApi "github.com/openshift/backplane-api/pkg/client" 26 | 27 | "github.com/openshift/backplane-cli/pkg/backplaneapi" 28 | "github.com/openshift/backplane-cli/pkg/cli/config" 29 | "github.com/openshift/backplane-cli/pkg/utils" 30 | ) 31 | 32 | func newGetManagedJobCmd() *cobra.Command { 33 | cmd := &cobra.Command{ 34 | Use: "get [job name]", 35 | Aliases: []string{"ls", "list"}, 36 | Short: "Get a managedjob or a list of managedjobs if job name not specified", 37 | Args: cobra.MaximumNArgs(1), 38 | SilenceUsage: true, 39 | RunE: func(cmd *cobra.Command, args []string) error { 40 | // ======== Parsing Flags ======== 41 | urlFlag, err := cmd.Flags().GetString("url") 42 | if err != nil { 43 | return err 44 | } 45 | 46 | clusterKey, err := cmd.Flags().GetString("cluster-id") 47 | if err != nil { 48 | return err 49 | } 50 | 51 | rawFlag, err := cmd.Flags().GetBool("raw") 52 | if err != nil { 53 | return err 54 | } 55 | 56 | // ======== Parsing Args ======== 57 | managedJobNameArg := "" 58 | if len(args) > 0 { 59 | managedJobNameArg = args[0] 60 | } 61 | 62 | // ======== Initialize backplaneURL ======== 63 | bpConfig, err := config.GetBackplaneConfiguration() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | bpCluster, err := utils.DefaultClusterUtils.GetBackplaneCluster(clusterKey) 69 | if err != nil { 70 | return err 71 | } 72 | backplaneHost := bpConfig.URL 73 | 74 | clusterID := bpCluster.ClusterID 75 | 76 | if urlFlag != "" { 77 | backplaneHost = urlFlag 78 | } 79 | 80 | client, err := backplaneapi.DefaultClientUtils.MakeRawBackplaneAPIClient(backplaneHost) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | // ======== Call Endpoint ======== 86 | var jobs = make([]*BackplaneApi.Job, 0) 87 | if managedJobNameArg != "" { 88 | // Get single job 89 | resp, err := client.GetRun(context.TODO(), clusterID, managedJobNameArg) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | if resp.StatusCode != http.StatusOK { 95 | return utils.TryPrintAPIError(resp, rawFlag) 96 | } 97 | 98 | jobResp, err := BackplaneApi.ParseGetRunResponse(resp) 99 | 100 | if err != nil { 101 | return fmt.Errorf("unable to parse response body from backplane: \n Status Code: %d", resp.StatusCode) 102 | } 103 | 104 | jobs = append(jobs, jobResp.JSON200) 105 | } else { 106 | resp, err := client.GetAllJobs(context.TODO(), clusterID) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | if resp.StatusCode != http.StatusOK { 112 | return utils.TryPrintAPIError(resp, rawFlag) 113 | } 114 | 115 | jobResp, err := BackplaneApi.ParseGetAllJobsResponse(resp) 116 | 117 | if err != nil { 118 | return fmt.Errorf("unable to parse response body from backplane: \n Status Code: %d", resp.StatusCode) 119 | } 120 | 121 | for _, j := range *jobResp.JSON200 { 122 | job := j 123 | jobs = append(jobs, &job) 124 | } 125 | } 126 | 127 | // ======== Render Results ======== 128 | headings := []string{"jobid", "status", "namespace", "start", "script"} 129 | rows := make([][]string, 0) 130 | for _, s := range jobs { 131 | rows = append(rows, []string{*s.JobId, string(*s.JobStatus.Status), *s.JobStatus.Namespace, s.JobStatus.Start.String(), *s.JobStatus.Script.CanonicalName}) 132 | } 133 | 134 | utils.RenderTable(headings, rows) 135 | 136 | return nil 137 | }, 138 | } 139 | 140 | return cmd 141 | } 142 | -------------------------------------------------------------------------------- /pkg/remediation/remediation.go: -------------------------------------------------------------------------------- 1 | package remediation 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | 9 | ocmsdk "github.com/openshift-online/ocm-sdk-go" 10 | BackplaneApi "github.com/openshift/backplane-api/pkg/client" 11 | "github.com/openshift/backplane-cli/pkg/backplaneapi" 12 | "github.com/openshift/backplane-cli/pkg/cli/config" 13 | "github.com/openshift/backplane-cli/pkg/ocm" 14 | "github.com/openshift/backplane-cli/pkg/utils" 15 | "k8s.io/client-go/rest" 16 | ) 17 | 18 | // DoCreateRemediation creates a remediation instance for a cluster using the Backplane API. 19 | // It sends a request to create a remediation and returns the proxy URI and remediation instance ID. 20 | // The function takes API endpoint, cluster ID, access token, and remediation name as parameters. 21 | func DoCreateRemediation(api string, clusterID string, accessToken string, remediationName string) (proxyURI string, remediationInstanceID string, err error) { 22 | client, err := backplaneapi.DefaultClientUtils.MakeRawBackplaneAPIClientWithAccessToken(api, accessToken) 23 | if err != nil { 24 | return "", "", fmt.Errorf("unable to create backplane api client") 25 | } 26 | 27 | resp, err := client.CreateRemediation(context.TODO(), clusterID, &BackplaneApi.CreateRemediationParams{RemediationName: remediationName}) 28 | if err != nil { 29 | return "", "", err 30 | } 31 | if resp.StatusCode != http.StatusOK { 32 | return "", "", utils.TryPrintAPIError(resp, false) 33 | } 34 | 35 | remediationResponse, err := BackplaneApi.ParseCreateRemediationResponse(resp) 36 | 37 | if err != nil { 38 | return "", "", fmt.Errorf("unable to parse response body from backplane: \n Status Code: %d", resp.StatusCode) 39 | } 40 | 41 | return api + *remediationResponse.JSON200.ProxyUri, remediationResponse.JSON200.RemediationInstanceId, nil 42 | } 43 | 44 | // CreateRemediationWithConn creates a remediation instance and returns a configured Kubernetes client. 45 | // This function can be used to programmatically interact with the Backplane API. 46 | // It creates a rest.Config that can be used with Kubernetes client libraries. 47 | func CreateRemediationWithConn(bp config.BackplaneConfiguration, ocmConnection *ocmsdk.Connection, clusterID string, remediationName string) (config *rest.Config, remediationInstanceID string, err error) { 48 | accessToken, err := ocm.DefaultOCMInterface.GetOCMAccessTokenWithConn(ocmConnection) 49 | if err != nil { 50 | return nil, "", err 51 | } 52 | 53 | bpAPIClusterURL, remediationInstanceID, err := DoCreateRemediation(bp.URL, clusterID, *accessToken, remediationName) 54 | if err != nil { 55 | return nil, "", err 56 | } 57 | 58 | cfg := &rest.Config{ 59 | Host: bpAPIClusterURL, 60 | BearerToken: *accessToken, 61 | } 62 | 63 | if bp.ProxyURL != nil { 64 | cfg.Proxy = func(r *http.Request) (*url.URL, error) { 65 | return url.Parse(*bp.ProxyURL) 66 | } 67 | } 68 | return cfg, remediationInstanceID, nil 69 | } 70 | 71 | // DoDeleteRemediation deletes a remediation instance using the Backplane API. 72 | // It takes the API endpoint, cluster ID, access token, and remediation instance ID as parameters. 73 | // Returns an error if the deletion fails or if the API returns a non-success status. 74 | func DoDeleteRemediation(api string, clusterID string, accessToken string, remediationInstanceID string) error { 75 | client, err := backplaneapi.DefaultClientUtils.MakeRawBackplaneAPIClientWithAccessToken(api, accessToken) 76 | if err != nil { 77 | return fmt.Errorf("unable to create backplane api client") 78 | } 79 | 80 | resp, err := client.DeleteRemediation(context.TODO(), clusterID, &BackplaneApi.DeleteRemediationParams{RemediationInstanceId: remediationInstanceID}) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | if resp.StatusCode != http.StatusOK { 86 | return utils.TryPrintAPIError(resp, false) 87 | } 88 | 89 | return nil 90 | } 91 | 92 | // DeleteRemediationWithConn can be used to programtically interact with backplaneapi 93 | func DeleteRemediationWithConn(bp config.BackplaneConfiguration, ocmConnection *ocmsdk.Connection, clusterID string, remediationInstanceID string) error { 94 | accessToken, err := ocm.DefaultOCMInterface.GetOCMAccessTokenWithConn(ocmConnection) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | return DoDeleteRemediation(bp.URL, clusterID, *accessToken, remediationInstanceID) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/pagerduty/mocks/clientMock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/openshift/backplane-cli/pkg/pagerduty (interfaces: PagerDutyClient) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=./pkg/pagerduty/mocks/clientMock.go -package=mocks github.com/openshift/backplane-cli/pkg/pagerduty PagerDutyClient 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | pagerduty "github.com/PagerDuty/go-pagerduty" 17 | gomock "go.uber.org/mock/gomock" 18 | ) 19 | 20 | // MockPagerDutyClient is a mock of PagerDutyClient interface. 21 | type MockPagerDutyClient struct { 22 | ctrl *gomock.Controller 23 | recorder *MockPagerDutyClientMockRecorder 24 | isgomock struct{} 25 | } 26 | 27 | // MockPagerDutyClientMockRecorder is the mock recorder for MockPagerDutyClient. 28 | type MockPagerDutyClientMockRecorder struct { 29 | mock *MockPagerDutyClient 30 | } 31 | 32 | // NewMockPagerDutyClient creates a new mock instance. 33 | func NewMockPagerDutyClient(ctrl *gomock.Controller) *MockPagerDutyClient { 34 | mock := &MockPagerDutyClient{ctrl: ctrl} 35 | mock.recorder = &MockPagerDutyClientMockRecorder{mock} 36 | return mock 37 | } 38 | 39 | // EXPECT returns an object that allows the caller to indicate expected use. 40 | func (m *MockPagerDutyClient) EXPECT() *MockPagerDutyClientMockRecorder { 41 | return m.recorder 42 | } 43 | 44 | // Connect mocks base method. 45 | func (m *MockPagerDutyClient) Connect(authToken string, options ...pagerduty.ClientOptions) error { 46 | m.ctrl.T.Helper() 47 | varargs := []any{authToken} 48 | for _, a := range options { 49 | varargs = append(varargs, a) 50 | } 51 | ret := m.ctrl.Call(m, "Connect", varargs...) 52 | ret0, _ := ret[0].(error) 53 | return ret0 54 | } 55 | 56 | // Connect indicates an expected call of Connect. 57 | func (mr *MockPagerDutyClientMockRecorder) Connect(authToken any, options ...any) *gomock.Call { 58 | mr.mock.ctrl.T.Helper() 59 | varargs := append([]any{authToken}, options...) 60 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockPagerDutyClient)(nil).Connect), varargs...) 61 | } 62 | 63 | // GetServiceWithContext mocks base method. 64 | func (m *MockPagerDutyClient) GetServiceWithContext(ctx context.Context, serviceID string, opts *pagerduty.GetServiceOptions) (*pagerduty.Service, error) { 65 | m.ctrl.T.Helper() 66 | ret := m.ctrl.Call(m, "GetServiceWithContext", ctx, serviceID, opts) 67 | ret0, _ := ret[0].(*pagerduty.Service) 68 | ret1, _ := ret[1].(error) 69 | return ret0, ret1 70 | } 71 | 72 | // GetServiceWithContext indicates an expected call of GetServiceWithContext. 73 | func (mr *MockPagerDutyClientMockRecorder) GetServiceWithContext(ctx, serviceID, opts any) *gomock.Call { 74 | mr.mock.ctrl.T.Helper() 75 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceWithContext", reflect.TypeOf((*MockPagerDutyClient)(nil).GetServiceWithContext), ctx, serviceID, opts) 76 | } 77 | 78 | // ListIncidentAlerts mocks base method. 79 | func (m *MockPagerDutyClient) ListIncidentAlerts(incidentID string) (*pagerduty.ListAlertsResponse, error) { 80 | m.ctrl.T.Helper() 81 | ret := m.ctrl.Call(m, "ListIncidentAlerts", incidentID) 82 | ret0, _ := ret[0].(*pagerduty.ListAlertsResponse) 83 | ret1, _ := ret[1].(error) 84 | return ret0, ret1 85 | } 86 | 87 | // ListIncidentAlerts indicates an expected call of ListIncidentAlerts. 88 | func (mr *MockPagerDutyClientMockRecorder) ListIncidentAlerts(incidentID any) *gomock.Call { 89 | mr.mock.ctrl.T.Helper() 90 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListIncidentAlerts", reflect.TypeOf((*MockPagerDutyClient)(nil).ListIncidentAlerts), incidentID) 91 | } 92 | 93 | // ListIncidents mocks base method. 94 | func (m *MockPagerDutyClient) ListIncidents(arg0 pagerduty.ListIncidentsOptions) (*pagerduty.ListIncidentsResponse, error) { 95 | m.ctrl.T.Helper() 96 | ret := m.ctrl.Call(m, "ListIncidents", arg0) 97 | ret0, _ := ret[0].(*pagerduty.ListIncidentsResponse) 98 | ret1, _ := ret[1].(error) 99 | return ret0, ret1 100 | } 101 | 102 | // ListIncidents indicates an expected call of ListIncidents. 103 | func (mr *MockPagerDutyClientMockRecorder) ListIncidents(arg0 any) *gomock.Call { 104 | mr.mock.ctrl.T.Helper() 105 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListIncidents", reflect.TypeOf((*MockPagerDutyClient)(nil).ListIncidents), arg0) 106 | } 107 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/accessrequest/createAccessRequest.go: -------------------------------------------------------------------------------- 1 | package accessrequest 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/openshift/backplane-cli/pkg/accessrequest" 10 | 11 | logger "github.com/sirupsen/logrus" 12 | "github.com/spf13/cobra" 13 | 14 | "github.com/openshift/backplane-cli/pkg/login" 15 | "github.com/openshift/backplane-cli/pkg/ocm" 16 | "github.com/openshift/backplane-cli/pkg/utils" 17 | ) 18 | 19 | var ( 20 | options struct { 21 | reason string 22 | notificationIssueID string 23 | pendingDuration time.Duration 24 | approvalDuration time.Duration 25 | } 26 | ) 27 | 28 | // newCreateAccessRequestCmd returns cobra command 29 | func newCreateAccessRequestCmd() *cobra.Command { 30 | cmd := &cobra.Command{ 31 | Use: "create", 32 | Short: "Creates a new pending access request", 33 | Args: cobra.ExactArgs(0), 34 | SilenceUsage: true, 35 | SilenceErrors: true, 36 | RunE: runCreateAccessRequest, 37 | } 38 | 39 | cmd.Flags().StringVarP( 40 | &options.reason, 41 | "reason", 42 | "r", 43 | "", 44 | "Reason/justification passed through the access request to the customer. "+ 45 | "Reason will be read from the kube context (unless --cluster-id is set) or prompted if the option is not set.") 46 | 47 | cmd.Flags().StringVarP( 48 | &options.notificationIssueID, 49 | "notification-issue", 50 | "n", 51 | "", 52 | "JIRA issue used for notifications when the access request is approved or denied. "+ 53 | "Issue needs to belong to the OHSS project on production and to the SDAINT project for staging & integration. "+ 54 | "Issue will automatically be created in the proper project if the option is not set.") 55 | 56 | cmd.Flags().DurationVarP( 57 | &options.approvalDuration, 58 | "approval-duration", 59 | "d", 60 | 8*time.Hour, 61 | "The maximal period of time during which the access request can stay approved") 62 | 63 | return cmd 64 | } 65 | 66 | func retrieveOrPromptReason(cmd *cobra.Command) string { 67 | if utils.CheckValidPrompt() { 68 | clusterKey, err := cmd.Flags().GetString("cluster-id") 69 | 70 | if err == nil && clusterKey == "" { 71 | config, err := utils.ReadKubeconfigRaw() 72 | 73 | if err == nil { 74 | reasons := login.GetElevateContextReasons(config) 75 | for _, reason := range reasons { 76 | if reason != "" { 77 | fmt.Printf("Reason for elevations read from the kube config: %s\n", reason) 78 | if strings.ToLower(utils.AskQuestionFromPrompt("Do you want to use this as the reason/justification for the access request to create (Y/n)? ")) != "n" { 79 | return reason 80 | } 81 | break 82 | } 83 | } 84 | } else { 85 | logger.Warnf("won't extract the elevation reason from the kube context which failed to be read: %v", err) 86 | } 87 | } 88 | } 89 | 90 | return utils.AskQuestionFromPrompt("Please enter a reason/justification for the access request to create: ") 91 | } 92 | 93 | // runCreateAccessRequest creates access request for the given cluster 94 | func runCreateAccessRequest(cmd *cobra.Command, args []string) error { 95 | clusterID, err := accessrequest.GetClusterID(cmd) 96 | if err != nil { 97 | return fmt.Errorf("failed to compute cluster ID: %v", err) 98 | } 99 | 100 | ocmConnection, err := ocm.DefaultOCMInterface.SetupOCMConnection() 101 | if err != nil { 102 | return fmt.Errorf("failed to create OCM connection: %v", err) 103 | } 104 | 105 | accessRequest, err := accessrequest.GetAccessRequest(ocmConnection, clusterID) 106 | 107 | if err != nil { 108 | return err 109 | } 110 | 111 | if accessRequest != nil { 112 | accessrequest.PrintAccessRequest(clusterID, accessRequest) 113 | 114 | return fmt.Errorf("there is already an active access request for cluster '%s', eventually consider expiring it running 'ocm-backplane accessrequest expire'", clusterID) 115 | } 116 | 117 | reason := options.reason 118 | if reason == "" { 119 | reason = retrieveOrPromptReason(cmd) 120 | if reason == "" { 121 | return errors.New("no reason/justification, consider using the --reason option with a non empty string") 122 | } 123 | } 124 | 125 | accessRequest, err = accessrequest.CreateAccessRequest(ocmConnection, clusterID, reason, options.notificationIssueID, options.approvalDuration) 126 | 127 | if err != nil { 128 | return err 129 | } 130 | 131 | accessrequest.PrintAccessRequest(clusterID, accessRequest) 132 | 133 | return nil 134 | } 135 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/script/listScripts.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2021 Red Hat, Inc 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package script 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | "net/http" 22 | "strings" 23 | 24 | "github.com/spf13/cobra" 25 | 26 | bpclient "github.com/openshift/backplane-api/pkg/client" 27 | 28 | "github.com/openshift/backplane-cli/pkg/backplaneapi" 29 | "github.com/openshift/backplane-cli/pkg/ocm" 30 | "github.com/openshift/backplane-cli/pkg/utils" 31 | ) 32 | 33 | func newListScriptCmd() *cobra.Command { 34 | cmd := &cobra.Command{ 35 | Use: "list", 36 | Aliases: []string{"ls", "get"}, 37 | Short: "List backplane scripts runnable by the current user", 38 | SilenceUsage: true, 39 | RunE: func(cmd *cobra.Command, args []string) error { 40 | // ======== Parsing Flags ======== 41 | urlFlag, err := cmd.Flags().GetString("url") 42 | if err != nil { 43 | return err 44 | } 45 | 46 | clusterKey, err := cmd.Flags().GetString("cluster-id") 47 | if err != nil { 48 | return err 49 | } 50 | 51 | rawFlag, err := cmd.Flags().GetBool("raw") 52 | if err != nil { 53 | return err 54 | } 55 | 56 | allFlag, err := cmd.Flags().GetBool("all") 57 | if err != nil { 58 | return err 59 | } 60 | // ======== Initialize backplaneURL ======== 61 | backplaneHost := urlFlag 62 | if backplaneHost == "" { 63 | bpCluster, err := utils.DefaultClusterUtils.GetBackplaneCluster(clusterKey, urlFlag) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | backplaneHost = bpCluster.BackplaneHost 69 | } 70 | 71 | client, err := backplaneapi.DefaultClientUtils.MakeRawBackplaneAPIClient(backplaneHost) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | // ======== Initialize cluster ID from config ======== 77 | if clusterKey == "" { 78 | configCluster, err := utils.DefaultClusterUtils.GetBackplaneClusterFromConfig() 79 | if err != nil { 80 | return err 81 | } 82 | clusterKey = configCluster.ClusterID 83 | } 84 | 85 | // ======== Transform clusterKey to clusterID (clusterKey can be name, ID external ID) ======== 86 | clusterID, _, err := ocm.DefaultOCMInterface.GetTargetCluster(clusterKey) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | // ======== Call Endpoint ======== 92 | var resp *http.Response 93 | if allFlag { 94 | resp, err = client.GetAllScriptsByCluster(context.TODO(), clusterID) 95 | } else { 96 | resp, err = client.GetScriptsByCluster(context.TODO(), clusterID, &bpclient.GetScriptsByClusterParams{}) 97 | } 98 | if err != nil { 99 | return err 100 | } 101 | 102 | if resp.StatusCode != http.StatusOK { 103 | return utils.TryPrintAPIError(resp, rawFlag) 104 | } 105 | 106 | // ======== Render Table ======== 107 | listResp, err := bpclient.ParseGetScriptsByClusterResponse(resp) 108 | if err != nil { 109 | return fmt.Errorf("unable to parse response body from backplane: Status Code: %d", resp.StatusCode) 110 | } 111 | 112 | scriptList := *(*[]bpclient.Script)(listResp.JSON200) 113 | 114 | if len(scriptList) == 0 { 115 | return fmt.Errorf("no scripts found") 116 | } 117 | 118 | headings := []string{"NAME", "DESCRIPTION"} 119 | if allFlag { 120 | headings = append(headings, "ALLOWED GROUPS") 121 | } 122 | 123 | rows := make([][]string, 0) 124 | for _, s := range scriptList { 125 | row := []string{*s.CanonicalName, *s.Description} 126 | if allFlag { 127 | var groups string 128 | if s.AllowedGroups != nil && len(*s.AllowedGroups) > 0 { 129 | groups = strings.Join(*s.AllowedGroups, ", ") 130 | } else { 131 | groups = "N/A" 132 | } 133 | row = append(row, groups) 134 | } 135 | rows = append(rows, row) 136 | } 137 | 138 | utils.RenderTabbedTable(headings, rows) 139 | 140 | return nil 141 | }, 142 | } 143 | 144 | cmd.Flags().Bool("all", false, "Show all scripts with allowed groups") 145 | return cmd 146 | } 147 | -------------------------------------------------------------------------------- /cmd/ocm-backplane/config/troubleshoot.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | "k8s.io/client-go/tools/clientcmd" 11 | 12 | "github.com/openshift/backplane-cli/pkg/cli/config" 13 | "github.com/openshift/backplane-cli/pkg/ocm" 14 | ) 15 | 16 | type troubleshootOptions struct { 17 | } 18 | 19 | func newTroubleshootOptions() *troubleshootOptions { 20 | return &troubleshootOptions{} 21 | } 22 | 23 | func newTroubleshootCmd() *cobra.Command { 24 | ops := newTroubleshootOptions() 25 | troubleshootCmd := &cobra.Command{ 26 | Use: "troubleshoot", 27 | Short: "Show the debug info of backplane", 28 | Long: `It prints the debug info for troubleshooting backplane issues. 29 | `, 30 | Args: cobra.ExactArgs(0), 31 | SilenceUsage: true, 32 | RunE: ops.run, 33 | } 34 | return troubleshootCmd 35 | } 36 | 37 | var ( 38 | // print info when the thing is correct 39 | printCorrect = func(format string, a ...any) { 40 | fmt.Printf("[V] "+format, a...) 41 | } 42 | // print info when the thing is wrong 43 | printWrong = func(format string, a ...any) { 44 | fmt.Printf("[X] "+format, a...) 45 | } 46 | // print info when the thing is not wrong but need attention 47 | printNotice = func(format string, a ...any) { 48 | fmt.Printf("[-] "+format, a...) 49 | } 50 | // normal printf 51 | printf = func(format string, a ...any) { 52 | fmt.Printf(format, a...) 53 | } 54 | // execute oc get proxy commands in OS 55 | execOCProxy = func() ([]byte, error) { 56 | return exec.Command("bash", "-c", "oc config view -o jsonpath='{.clusters[0].cluster.proxy-url}'").Output() 57 | } 58 | ) 59 | 60 | var getBackplaneConfiguration = config.GetBackplaneConfiguration 61 | 62 | // Print backplane-cli related info 63 | func (o *troubleshootOptions) checkBPCli() error { 64 | configFilePath, err := config.GetConfigFilePath() 65 | if err != nil { 66 | printWrong("Failed to get backplane-cli configuration path: %v\n", err) 67 | } else { 68 | printCorrect("backplane-cli configuration path: %s\n", configFilePath) 69 | // Check if the config file exists 70 | if _, err = os.Stat(configFilePath); err != nil { 71 | printNotice("config file not found: %s\n", configFilePath) 72 | } 73 | } 74 | 75 | currentBPConfig, err := getBackplaneConfiguration() 76 | if err != nil { 77 | printWrong("Failed to read backplane-cli config file: %v\n", err) 78 | } else { 79 | if currentBPConfig.ProxyURL == nil { 80 | printNotice("proxy in backplane-cli config:\n") 81 | } else { 82 | printCorrect("proxy in backplane-cli config: %s\n", *currentBPConfig.ProxyURL) 83 | } 84 | } 85 | return nil 86 | } 87 | 88 | // Print OCM related info 89 | func (o *troubleshootOptions) checkOCM() error { 90 | ocmEnv, err := ocm.DefaultOCMInterface.GetOCMEnvironment() 91 | if err != nil { 92 | printWrong("Failed to get OCM environment: %v\n", err) 93 | } else { 94 | printCorrect("backplane url from OCM: %s\n", ocmEnv.BackplaneURL()) 95 | } 96 | return nil 97 | } 98 | 99 | // Print OC related info 100 | func (o *troubleshootOptions) checkOC() error { 101 | cfg, err := clientcmd.BuildConfigFromFlags("", clientcmd.NewDefaultPathOptions().GetDefaultFilename()) 102 | if err != nil { 103 | printWrong("Failed to get OC configuration: %v\n", err) 104 | } else { 105 | printCorrect("oc server url: %s\n", cfg.Host) 106 | } 107 | // get the proxy url 108 | // we might refine it by client-go 109 | // https://github.com/openshift/oc/blob/master/vendor/k8s.io/kubectl/pkg/cmd/config/view.go 110 | hasProxy := false 111 | proxyURL := "" 112 | getOCProxyOutput, err := execOCProxy() 113 | if err != nil { 114 | printWrong("Failed to get proxy in OC configuration: %v\n", err) 115 | } else { 116 | proxyURL = strings.TrimSpace(string(getOCProxyOutput)) 117 | if len(proxyURL) > 0 { 118 | hasProxy = true 119 | printCorrect("proxy in OC configuration: %s\n", proxyURL) 120 | } else { 121 | printNotice("no proxy in OC configuration") 122 | } 123 | } 124 | // Verify network - proxy connectivity 125 | if hasProxy { 126 | printf("To verify proxy connectivity, run:\n HTTPS_PROXY=%s curl -Iv https://www.redhat.com \n", proxyURL) 127 | } 128 | return nil 129 | } 130 | 131 | func (o *troubleshootOptions) run(cmd *cobra.Command, argv []string) error { 132 | err := o.checkBPCli() 133 | if err != nil { 134 | return err 135 | } 136 | err = o.checkOCM() 137 | if err != nil { 138 | return err 139 | } 140 | err = o.checkOC() 141 | if err != nil { 142 | return err 143 | } 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /pkg/utils/renderingutils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | "text/tabwriter" 10 | 11 | "github.com/olekukonko/tablewriter" 12 | "golang.org/x/term" 13 | ) 14 | 15 | // RenderTabbedTable only uses tabs and renders based on terminal width available 16 | // It keeps the first column in it's full length and truncates others 17 | func RenderTabbedTable(headers []string, data [][]string) { 18 | columnPadding := 2 19 | maxColumnWidth := calculateOptimalWidthsForColumns(data, columnPadding) 20 | 21 | writer := tabwriter.NewWriter(os.Stdout, 0, 0, columnPadding, ' ', tabwriter.TabIndent) 22 | 23 | // print the headers 24 | _, _ = fmt.Fprintf(writer, "%s", strings.Join(headers, "\t")) 25 | _, _ = fmt.Fprintln(writer) 26 | 27 | // print the rows 28 | for _, row := range data { 29 | _, _ = fmt.Fprintf(writer, "%s", strings.Join(truncateColumns(row, maxColumnWidth), "\t")) 30 | _, _ = fmt.Fprintln(writer) 31 | } 32 | 33 | _ = writer.Flush() 34 | } 35 | 36 | // calculateOptimalWidthsForColumns calculates optimal column width for table rendering. 37 | // It reserves space for the first column and distributes remaining terminal width among other columns. 38 | // Returns a fallback width of 200 if terminal width cannot be determined. 39 | func calculateOptimalWidthsForColumns(data [][]string, columnPadding int) int { 40 | // detect terminal width 41 | terminalWidth, _, err := term.GetSize(0) 42 | if err != nil { 43 | // if the width cannot be read use a fallback value 44 | return 200 45 | } 46 | 47 | maxFirstColumnContentLength := int(0) 48 | for _, row := range data { 49 | if maxFirstColumnContentLength < len(row[0]) { 50 | maxFirstColumnContentLength = len(row[0]) // give the first column as much as it needs 51 | } 52 | } 53 | // take the first column out and distribute the rest of the width uniformly, smaller columns tend to waste space 54 | maxColumnWidth := ((terminalWidth - maxFirstColumnContentLength - columnPadding) / (len(data[0]) - 1)) - 3 // compensate for ... 55 | 56 | return maxColumnWidth 57 | } 58 | 59 | // truncateColumns truncates columns (except the first one) to fit within the specified width. 60 | // If content exceeds maxColumnWidth or contains newlines, it's truncated with "..." suffix. 61 | func truncateColumns(row []string, maxColumnWidth int) []string { 62 | processedRow := []string{} 63 | 64 | for column, content := range row { 65 | newLine := strings.Index(content, "\n") 66 | processedContent := content 67 | if column > 0 { 68 | if newLine >= 0 && newLine >= maxColumnWidth { 69 | processedContent = content[:maxColumnWidth] + "..." 70 | } else if newLine >= 0 { 71 | processedContent = content[:newLine] + "..." 72 | } else if len(content) >= maxColumnWidth { 73 | processedContent = content[:maxColumnWidth] + "..." 74 | } else { 75 | processedContent = content 76 | } 77 | } 78 | 79 | processedRow = append(processedRow, processedContent) 80 | } 81 | 82 | return processedRow 83 | } 84 | 85 | // RenderTable renders data in a formatted table using the tablewriter library. 86 | // It displays the headers and data with proper alignment and formatting. 87 | func RenderTable(headers []string, data [][]string) { 88 | table := tablewriter.NewWriter(os.Stdout) 89 | table.SetHeader(headers) 90 | table.SetAutoWrapText(true) 91 | table.SetAutoFormatHeaders(true) 92 | table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) 93 | table.SetAlignment(tablewriter.ALIGN_LEFT) 94 | table.SetCenterSeparator("") 95 | table.SetColumnSeparator("") 96 | table.SetRowSeparator("") 97 | table.SetHeaderLine(false) 98 | table.SetBorder(false) 99 | table.SetTablePadding("\t") // pad with tabs 100 | table.SetNoWhiteSpace(true) 101 | table.AppendBulk(data) // Add Bulk Data 102 | table.Render() 103 | } 104 | 105 | // RenderJSON is an effectual function that renders the reader as JSON 106 | // returns err if render fails 107 | func RenderJSON(reader io.Reader) error { 108 | body, err := io.ReadAll(reader) 109 | if err != nil { 110 | return err 111 | } 112 | resString, err := json.MarshalIndent(body, "", " ") 113 | if err != nil { 114 | return err 115 | } 116 | fmt.Println(string(resString)) 117 | return nil 118 | } 119 | 120 | // RenderJSONBytes is an effectual function that renders the reader as JSON 121 | // returns err if render fails 122 | func RenderJSONBytes(i interface{}) error { 123 | resString, err := json.MarshalIndent(i, "", " ") 124 | if err != nil { 125 | return err 126 | } 127 | fmt.Println(string(resString)) 128 | return nil 129 | } 130 | --------------------------------------------------------------------------------