Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions pkg/tools/base/gobin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package base

import (
"debug/buildinfo"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/openshift/backplane-tools/pkg/utils"
)

type GoBin struct {
Default
Module string
Branch string
}

func (g *GoBin) LatestVersion() (string, error) {
return g.Branch, nil
}

func (g *GoBin) Install() error {
Comment on lines +15 to +25
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Implement InstalledVersion() for GoBin.

GoBin now installs into a revision-specific directory, but it still inherits Default.InstalledVersion(), which scrapes a hash out of the binary bytes. That won't reliably return the vcs.revision you extract during install, so list installed and upgrade decisions can be wrong.

🔧 Proposed fix
 type GoBin struct {
 	Default
 	Module string
 	Branch string
 }
 
 func (g *GoBin) LatestVersion() (string, error) {
 	return g.Branch, nil
 }
+
+func (g *GoBin) InstalledVersion() (string, error) {
+	return extractVCSRevision(g.SymlinkPath())
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type GoBin struct {
Default
Module string
Branch string
}
func (g *GoBin) LatestVersion() (string, error) {
return g.Branch, nil
}
func (g *GoBin) Install() error {
type GoBin struct {
Default
Module string
Branch string
}
func (g *GoBin) LatestVersion() (string, error) {
return g.Branch, nil
}
func (g *GoBin) InstalledVersion() (string, error) {
return extractVCSRevision(g.SymlinkPath())
}
func (g *GoBin) Install() error {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/tools/base/gobin.go` around lines 14 - 24, Default.InstalledVersion() is
unreliable for GoBin because GoBin.Install writes into a revision-specific
directory; implement GoBin.InstalledVersion() to return the actual installed
vcs.revision instead of inheriting Default behaviour by locating the installed
binary directory used by Install (use the same path logic as GoBin.Install),
read the revision metadata file that Install writes (or parse the revision from
the directory name), and return that revision string (ensure signature
InstalledVersion() (string, error) on type GoBin).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default.InstalledVersion() works correctly here — it extracts the version from the directory name in the symlink path (splits on path separator and takes element [1]). Since GoBin.Install() uses the module pseudo-version as the directory name, the inherited method returns exactly that pseudo-version. Verified with backplane-tools list installed showing v1.3.1-0.20260423145906-289db43d04d1.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the Default.InstalledVersion() implementation
rg -n "InstalledVersion" --type=go -A 10

Length of output: 3423


@geowa4, you're right — I misread the implementation. Default.InstalledVersion() resolves the latest/ symlink and extracts the version from the directory name in the resolved path, not from binary bytes. Since GoBin.Install() names the version directory after the module pseudo-version, the inherited method returns exactly that value. My review comment was a false positive. Thanks for the clarification and for verifying it end-to-end!


📝 Learnings were identified and not saved (knowledge base disabled). Enable

goBin, err := exec.LookPath("go")
if err != nil {
return fmt.Errorf("failed to locate 'go' on $PATH: the Go toolchain is required to install %s: %w", g.Name(), err)
}

ref := g.Module + "@" + g.Branch
cmd := exec.Command(goBin, "install", ref)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to run 'go install %s': %w", ref, err)
}

gobin, err := resolveGOBIN(goBin)
if err != nil {
return err
}
builtBinary := filepath.Join(gobin, g.ExecutableName())

if _, err := os.Stat(builtBinary); err != nil {
return fmt.Errorf("expected binary at '%s' after 'go install', but it was not found: %w", builtBinary, err)
}

version, err := extractModuleVersion(builtBinary, g.Module)
if err != nil {
return err
}

toolDir := g.ToolDir()
versionedDir := filepath.Join(toolDir, version)
if err := os.MkdirAll(versionedDir, os.FileMode(0o755)); err != nil {
return fmt.Errorf("failed to create version-specific directory '%s': %w", versionedDir, err)
}

src, err := os.Open(builtBinary)
if err != nil {
return fmt.Errorf("failed to open built binary '%s': %w", builtBinary, err)
}
defer src.Close()

destPath := filepath.Join(versionedDir, g.ExecutableName())
if err := utils.WriteFile(src, destPath, 0o755); err != nil {
return fmt.Errorf("failed to copy binary to '%s': %w", destPath, err)
}

latestFilePath := g.SymlinkPath()
if err := os.Remove(latestFilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove existing '%s' symlink at '%s': %w", g.ExecutableName(), LatestDir, err)
}

if err := os.Symlink(destPath, latestFilePath); err != nil {
return fmt.Errorf("failed to link new '%s' binary to '%s': %w", g.ExecutableName(), LatestDir, err)
}

return nil
}

func resolveGOBIN(goBin string) (string, error) {
out, err := exec.Command(goBin, "env", "GOBIN").Output()
if err != nil {
return "", fmt.Errorf("failed to run 'go env GOBIN': %w", err)
}
gobin := strings.TrimSpace(string(out))
if gobin != "" {
return gobin, nil
}

out, err = exec.Command(goBin, "env", "GOPATH").Output()
if err != nil {
return "", fmt.Errorf("failed to run 'go env GOPATH': %w", err)
}
gopath := strings.TrimSpace(string(out))
entries := filepath.SplitList(gopath)
if len(entries) == 0 || entries[0] == "" {
return "", errors.New("both GOBIN and GOPATH are empty; cannot determine where 'go install' placed the binary")
}
return filepath.Join(entries[0], "bin"), nil
}

func extractModuleVersion(binaryPath, module string) (string, error) {
info, err := buildinfo.ReadFile(binaryPath)
if err != nil {
return "", fmt.Errorf("failed to read build info from '%s': %w", binaryPath, err)
}
if info.Main.Path == module {
return info.Main.Version, nil
}
return "", fmt.Errorf("module '%s' not found in build info of '%s'", module, binaryPath)
}
95 changes: 5 additions & 90 deletions pkg/tools/self/self.go
Original file line number Diff line number Diff line change
@@ -1,104 +1,19 @@
package self

import (
"fmt"
"os"
"path/filepath"
"strings"

gogithub "github.com/google/go-github/v51/github"
"github.com/openshift/backplane-tools/pkg/sources/github"
"github.com/openshift/backplane-tools/pkg/tools/base"
"github.com/openshift/backplane-tools/pkg/utils"
)

// Tool implements the interface to manage the 'backplane-tools' binary
type Tool struct {
base.Github
base.GoBin
}

func New() *Tool {
t := &Tool{
Github: base.Github{
return &Tool{
GoBin: base.GoBin{
Default: base.NewDefault("backplane-tools"),
Source: github.NewSource("openshift", "backplane-tools"),
Module: "github.com/openshift/backplane-tools",
Branch: "main",
},
}
return t
}

func (t *Tool) Install() error {
// Pull latest release from GH
release, err := t.Source.FetchLatestRelease()
if err != nil {
return err
}

matches := github.FindAssetsForArchAndOS(release.Assets)
if len(matches) != 1 {
return fmt.Errorf("unexpected number of assets found matching system spec: expected 1, got %d.\nMatching assets: %v", len(matches), matches)
}
toolArchiveAsset := matches[0]

matches = github.FindAssetsContaining([]string{"checksums.txt"}, release.Assets)
if len(matches) != 1 {
return fmt.Errorf("unexpected number of checksum assets found: expected 1, got %d.\nMatching assets: %v", len(matches), matches)
}
checksumAsset := matches[0]

// Download the arch- & os-specific assets
toolDir := t.ToolDir()
versionedDir := filepath.Join(toolDir, release.GetTagName())
err = os.MkdirAll(versionedDir, os.FileMode(0o755))
if err != nil {
return fmt.Errorf("failed to create version-specific directory '%s': %w", versionedDir, err)
}

err = t.Source.DownloadReleaseAssets([]*gogithub.ReleaseAsset{checksumAsset, toolArchiveAsset}, versionedDir)
if err != nil {
return fmt.Errorf("failed to download one or more assets: %w", err)
}

// Verify checksum of downloaded assets
toolArchiveFilepath := filepath.Join(versionedDir, toolArchiveAsset.GetName())
binarySum, err := utils.Sha256sum(toolArchiveFilepath)
if err != nil {
return fmt.Errorf("failed to calculate checksum for '%s': %w", toolArchiveFilepath, err)
}

checksumFilePath := filepath.Join(versionedDir, checksumAsset.GetName())
checksumLine, err := utils.GetLineInFileMatchingKey(checksumFilePath, toolArchiveAsset.GetName())
if err != nil {
return fmt.Errorf("failed to retrieve checksum from file '%s': %w", checksumFilePath, err)
}
checksumTokens := strings.Fields(checksumLine)
if len(checksumTokens) != 2 {
return fmt.Errorf("the checksum file '%s' is invalid: expected 2 fields, got %d", checksumFilePath, len(checksumTokens))
}
actual := checksumTokens[0]

toolExecutable := t.ExecutableName()
if strings.TrimSpace(binarySum) != strings.TrimSpace(actual) {
return fmt.Errorf("warning: Checksum for '%s' does not match the calculated value. Please retry installation. If issue persists, this tool can be downloaded manually at %s", toolExecutable, toolArchiveAsset.GetBrowserDownloadURL())
}

// Untar binary bundle
err = utils.Unarchive(toolArchiveFilepath, versionedDir)
if err != nil {
return fmt.Errorf("failed to unarchive the '%s' asset file '%s': %w", toolExecutable, toolArchiveFilepath, err)
}

// Link as latest
latestFilePath := t.SymlinkPath()
err = os.Remove(latestFilePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove existing '%s' binary at '%s': %w", toolExecutable, base.LatestDir, err)
}

toolBinaryFilepath := filepath.Join(versionedDir, toolExecutable)
err = os.Symlink(toolBinaryFilepath, latestFilePath)
if err != nil {
return fmt.Errorf("failed to link new '%s' binary to '%s': %w", toolExecutable, base.LatestDir, err)
}
return nil
}