Files
at-container-registry/cmd/credential-helper/cmd_update.go

259 lines
6.7 KiB
Go

package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
// tangledReleasesBase is the tangled.org path for the credential-helper's
// release repository. /tags/latest issues a 302 redirect to the latest tag,
// and /tags/{version}/download/{filename} serves goreleaser artifacts directly.
const tangledReleasesBase = "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64"
func newUpdateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
Short: "Update to the latest version",
RunE: runUpdate,
}
cmd.Flags().Bool("check", false, "Only check for updates, don't install")
return cmd
}
func runUpdate(cmd *cobra.Command, args []string) error {
checkOnly, _ := cmd.Flags().GetBool("check")
latest, err := fetchLatestVersion()
if err != nil {
return fmt.Errorf("checking for updates: %w", err)
}
if !isNewerVersion(latest, version) {
fmt.Printf("You're already running the latest version (%s)\n", version)
return nil
}
fmt.Printf("New version available: %s (current: %s)\n", latest, version)
if checkOnly {
return nil
}
if err := performUpdate(latest); err != nil {
return fmt.Errorf("update failed: %w", err)
}
fmt.Println("Update completed successfully!")
return nil
}
// fetchLatestVersion resolves the latest released version by reading the
// redirect Location header of {tangledReleasesBase}/tags/latest.
func fetchLatestVersion() (string, error) {
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(tangledReleasesBase + "/tags/latest")
if err != nil {
return "", fmt.Errorf("fetching latest tag: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
default:
return "", fmt.Errorf("expected redirect from tags/latest, got status %d", resp.StatusCode)
}
location := resp.Header.Get("Location")
if location == "" {
return "", fmt.Errorf("tags/latest returned redirect with no Location header")
}
u, err := url.Parse(location)
if err != nil {
return "", fmt.Errorf("parsing redirect location %q: %w", location, err)
}
tag := path.Base(u.Path)
if !strings.HasPrefix(tag, "v") {
return "", fmt.Errorf("unexpected tag in redirect location %q", location)
}
return tag, nil
}
// isNewerVersion compares two version strings (simple semver comparison)
func isNewerVersion(newVersion, currentVersion string) bool {
if currentVersion == "dev" {
return true
}
newV := strings.TrimPrefix(newVersion, "v")
curV := strings.TrimPrefix(currentVersion, "v")
newParts := strings.Split(newV, ".")
curParts := strings.Split(curV, ".")
for i := range min(len(newParts), len(curParts)) {
newNum := 0
if parsed, err := strconv.Atoi(newParts[i]); err == nil {
newNum = parsed
}
curNum := 0
if parsed, err := strconv.Atoi(curParts[i]); err == nil {
curNum = parsed
}
if newNum > curNum {
return true
}
if newNum < curNum {
return false
}
}
return len(newParts) > len(curParts)
}
// goreleaserArchiveName returns the archive filename goreleaser publishes for
// the given version and the current platform. The naming template lives in
// .goreleaser.yaml: docker-credential-atcr_{Version}_{Title(OS)}_{Arch} with
// amd64→x86_64 and 386→i386.
func goreleaserArchiveName(version string) string {
versionNoV := strings.TrimPrefix(version, "v")
os := strings.ToUpper(runtime.GOOS[:1]) + runtime.GOOS[1:]
arch := runtime.GOARCH
switch arch {
case "amd64":
arch = "x86_64"
case "386":
arch = "i386"
}
return fmt.Sprintf("docker-credential-atcr_%s_%s_%s.tar.gz", versionNoV, os, arch)
}
// performUpdate downloads and installs the new version
func performUpdate(latest string) error {
filename := goreleaserArchiveName(latest)
downloadURL := fmt.Sprintf("%s/tags/%s/download/%s", tangledReleasesBase, latest, filename)
fmt.Printf("Downloading update from %s...\n", downloadURL)
tmpDir, err := os.MkdirTemp("", "atcr-update-")
if err != nil {
return fmt.Errorf("creating temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "archive.tar.gz")
if err := downloadFile(downloadURL, archivePath); err != nil {
return fmt.Errorf("downloading: %w", err)
}
binaryPath := filepath.Join(tmpDir, "docker-credential-atcr")
if runtime.GOOS == "windows" {
binaryPath += ".exe"
}
if err := extractTarGz(archivePath, tmpDir); err != nil {
return fmt.Errorf("extracting archive: %w", err)
}
currentPath, err := os.Executable()
if err != nil {
return fmt.Errorf("getting current executable path: %w", err)
}
currentPath, err = filepath.EvalSymlinks(currentPath)
if err != nil {
return fmt.Errorf("resolving symlinks: %w", err)
}
fmt.Println("Verifying new binary...")
verifyCmd := exec.Command(binaryPath, "version")
if output, err := verifyCmd.Output(); err != nil {
return fmt.Errorf("new binary verification failed: %w", err)
} else {
fmt.Printf("New binary version: %s", string(output))
}
backupPath := currentPath + ".bak"
if err := os.Rename(currentPath, backupPath); err != nil {
return fmt.Errorf("backing up current binary: %w", err)
}
if err := copyFile(binaryPath, currentPath); err != nil {
os.Rename(backupPath, currentPath) //nolint:errcheck
return fmt.Errorf("installing new binary: %w", err)
}
if err := os.Chmod(currentPath, 0755); err != nil {
os.Remove(currentPath) //nolint:errcheck
os.Rename(backupPath, currentPath) //nolint:errcheck
return fmt.Errorf("setting permissions: %w", err)
}
os.Remove(backupPath) //nolint:errcheck
return nil
}
// downloadFile downloads a file from a URL to a local path
func downloadFile(url, destPath string) error {
resp, err := http.Get(url) //nolint:gosec
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned status %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// extractTarGz extracts a .tar.gz archive
func extractTarGz(archivePath, destDir string) error {
cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("tar failed: %s: %w", string(output), err)
}
return nil
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
input, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, input, 0755)
}