mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-06-09 08:42:35 +00:00
282 lines
7.1 KiB
Go
282 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// VersionAPIResponse is the response from /api/credential-helper/version
|
|
type VersionAPIResponse struct {
|
|
Latest string `json:"latest"`
|
|
DownloadURLs map[string]string `json:"download_urls"`
|
|
Checksums map[string]string `json:"checksums"`
|
|
ReleaseNotes string `json:"release_notes,omitempty"`
|
|
}
|
|
|
|
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")
|
|
|
|
// Default API URL
|
|
apiURL := "https://atcr.io/api/credential-helper/version"
|
|
|
|
// Try to get AppView URL from stored credentials
|
|
cfg, _ := loadConfig()
|
|
if cfg != nil {
|
|
for url := range cfg.Registries {
|
|
apiURL = url + "/api/credential-helper/version"
|
|
break
|
|
}
|
|
}
|
|
|
|
versionInfo, err := fetchVersionInfo(apiURL)
|
|
if err != nil {
|
|
return fmt.Errorf("checking for updates: %w", err)
|
|
}
|
|
|
|
if !isNewerVersion(versionInfo.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", versionInfo.Latest, version)
|
|
|
|
if checkOnly {
|
|
return nil
|
|
}
|
|
|
|
if err := performUpdate(versionInfo); err != nil {
|
|
return fmt.Errorf("update failed: %w", err)
|
|
}
|
|
|
|
fmt.Println("Update completed successfully!")
|
|
return nil
|
|
}
|
|
|
|
// fetchVersionInfo fetches version info from the AppView API
|
|
func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) {
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
resp, err := client.Get(apiURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching version info: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("version API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var versionInfo VersionAPIResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
|
|
return nil, fmt.Errorf("parsing version info: %w", err)
|
|
}
|
|
|
|
return &versionInfo, 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)
|
|
}
|
|
|
|
// getPlatformKey returns the platform key for the current OS/arch
|
|
func getPlatformKey() string {
|
|
return fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH)
|
|
}
|
|
|
|
// performUpdate downloads and installs the new version
|
|
func performUpdate(versionInfo *VersionAPIResponse) error {
|
|
platformKey := getPlatformKey()
|
|
|
|
downloadURL, ok := versionInfo.DownloadURLs[platformKey]
|
|
if !ok {
|
|
return fmt.Errorf("no download available for platform %s", platformKey)
|
|
}
|
|
|
|
expectedChecksum := versionInfo.Checksums[platformKey]
|
|
|
|
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 strings.HasSuffix(downloadURL, ".zip") {
|
|
archivePath = filepath.Join(tmpDir, "archive.zip")
|
|
}
|
|
|
|
if err := downloadFile(downloadURL, archivePath); err != nil {
|
|
return fmt.Errorf("downloading: %w", err)
|
|
}
|
|
|
|
if expectedChecksum != "" {
|
|
if err := verifyChecksum(archivePath, expectedChecksum); err != nil {
|
|
return fmt.Errorf("checksum verification failed: %w", err)
|
|
}
|
|
fmt.Println("Checksum verified.")
|
|
}
|
|
|
|
binaryPath := filepath.Join(tmpDir, "docker-credential-atcr")
|
|
if runtime.GOOS == "windows" {
|
|
binaryPath += ".exe"
|
|
}
|
|
|
|
if strings.HasSuffix(archivePath, ".zip") {
|
|
if err := extractZip(archivePath, tmpDir); err != nil {
|
|
return fmt.Errorf("extracting archive: %w", err)
|
|
}
|
|
} else {
|
|
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
|
|
}
|
|
|
|
// verifyChecksum verifies the SHA256 checksum of a file
|
|
func verifyChecksum(filePath, expected string) error {
|
|
if expected == "" {
|
|
return nil
|
|
}
|
|
// Checksums are optional until configured
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// extractZip extracts a .zip archive
|
|
func extractZip(archivePath, destDir string) error {
|
|
cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("unzip 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)
|
|
}
|