A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "runtime"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/spf13/cobra"
17)
18
19// VersionAPIResponse is the response from /api/credential-helper/version
20type VersionAPIResponse struct {
21 Latest string `json:"latest"`
22 DownloadURLs map[string]string `json:"download_urls"`
23 Checksums map[string]string `json:"checksums"`
24 ReleaseNotes string `json:"release_notes,omitempty"`
25}
26
27func newUpdateCmd() *cobra.Command {
28 cmd := &cobra.Command{
29 Use: "update",
30 Short: "Update to the latest version",
31 RunE: runUpdate,
32 }
33 cmd.Flags().Bool("check", false, "Only check for updates, don't install")
34 return cmd
35}
36
37func runUpdate(cmd *cobra.Command, args []string) error {
38 checkOnly, _ := cmd.Flags().GetBool("check")
39
40 // Default API URL
41 apiURL := "https://atcr.io/api/credential-helper/version"
42
43 // Try to get AppView URL from stored credentials
44 cfg, _ := loadConfig()
45 if cfg != nil {
46 for url := range cfg.Registries {
47 apiURL = url + "/api/credential-helper/version"
48 break
49 }
50 }
51
52 versionInfo, err := fetchVersionInfo(apiURL)
53 if err != nil {
54 return fmt.Errorf("checking for updates: %w", err)
55 }
56
57 if !isNewerVersion(versionInfo.Latest, version) {
58 fmt.Printf("You're already running the latest version (%s)\n", version)
59 return nil
60 }
61
62 fmt.Printf("New version available: %s (current: %s)\n", versionInfo.Latest, version)
63
64 if checkOnly {
65 return nil
66 }
67
68 if err := performUpdate(versionInfo); err != nil {
69 return fmt.Errorf("update failed: %w", err)
70 }
71
72 fmt.Println("Update completed successfully!")
73 return nil
74}
75
76// fetchVersionInfo fetches version info from the AppView API
77func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) {
78 client := &http.Client{
79 Timeout: 10 * time.Second,
80 }
81
82 resp, err := client.Get(apiURL)
83 if err != nil {
84 return nil, fmt.Errorf("fetching version info: %w", err)
85 }
86 defer resp.Body.Close()
87
88 if resp.StatusCode != http.StatusOK {
89 return nil, fmt.Errorf("version API returned status %d", resp.StatusCode)
90 }
91
92 var versionInfo VersionAPIResponse
93 if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
94 return nil, fmt.Errorf("parsing version info: %w", err)
95 }
96
97 return &versionInfo, nil
98}
99
100// isNewerVersion compares two version strings (simple semver comparison)
101func isNewerVersion(newVersion, currentVersion string) bool {
102 if currentVersion == "dev" {
103 return true
104 }
105
106 newV := strings.TrimPrefix(newVersion, "v")
107 curV := strings.TrimPrefix(currentVersion, "v")
108
109 newParts := strings.Split(newV, ".")
110 curParts := strings.Split(curV, ".")
111
112 for i := range min(len(newParts), len(curParts)) {
113 newNum := 0
114 if parsed, err := strconv.Atoi(newParts[i]); err == nil {
115 newNum = parsed
116 }
117 curNum := 0
118 if parsed, err := strconv.Atoi(curParts[i]); err == nil {
119 curNum = parsed
120 }
121
122 if newNum > curNum {
123 return true
124 }
125 if newNum < curNum {
126 return false
127 }
128 }
129
130 return len(newParts) > len(curParts)
131}
132
133// getPlatformKey returns the platform key for the current OS/arch
134func getPlatformKey() string {
135 return fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH)
136}
137
138// performUpdate downloads and installs the new version
139func performUpdate(versionInfo *VersionAPIResponse) error {
140 platformKey := getPlatformKey()
141
142 downloadURL, ok := versionInfo.DownloadURLs[platformKey]
143 if !ok {
144 return fmt.Errorf("no download available for platform %s", platformKey)
145 }
146
147 expectedChecksum := versionInfo.Checksums[platformKey]
148
149 fmt.Printf("Downloading update from %s...\n", downloadURL)
150
151 tmpDir, err := os.MkdirTemp("", "atcr-update-")
152 if err != nil {
153 return fmt.Errorf("creating temp directory: %w", err)
154 }
155 defer os.RemoveAll(tmpDir)
156
157 archivePath := filepath.Join(tmpDir, "archive.tar.gz")
158 if strings.HasSuffix(downloadURL, ".zip") {
159 archivePath = filepath.Join(tmpDir, "archive.zip")
160 }
161
162 if err := downloadFile(downloadURL, archivePath); err != nil {
163 return fmt.Errorf("downloading: %w", err)
164 }
165
166 if expectedChecksum != "" {
167 if err := verifyChecksum(archivePath, expectedChecksum); err != nil {
168 return fmt.Errorf("checksum verification failed: %w", err)
169 }
170 fmt.Println("Checksum verified.")
171 }
172
173 binaryPath := filepath.Join(tmpDir, "docker-credential-atcr")
174 if runtime.GOOS == "windows" {
175 binaryPath += ".exe"
176 }
177
178 if strings.HasSuffix(archivePath, ".zip") {
179 if err := extractZip(archivePath, tmpDir); err != nil {
180 return fmt.Errorf("extracting archive: %w", err)
181 }
182 } else {
183 if err := extractTarGz(archivePath, tmpDir); err != nil {
184 return fmt.Errorf("extracting archive: %w", err)
185 }
186 }
187
188 currentPath, err := os.Executable()
189 if err != nil {
190 return fmt.Errorf("getting current executable path: %w", err)
191 }
192 currentPath, err = filepath.EvalSymlinks(currentPath)
193 if err != nil {
194 return fmt.Errorf("resolving symlinks: %w", err)
195 }
196
197 fmt.Println("Verifying new binary...")
198 verifyCmd := exec.Command(binaryPath, "version")
199 if output, err := verifyCmd.Output(); err != nil {
200 return fmt.Errorf("new binary verification failed: %w", err)
201 } else {
202 fmt.Printf("New binary version: %s", string(output))
203 }
204
205 backupPath := currentPath + ".bak"
206 if err := os.Rename(currentPath, backupPath); err != nil {
207 return fmt.Errorf("backing up current binary: %w", err)
208 }
209
210 if err := copyFile(binaryPath, currentPath); err != nil {
211 os.Rename(backupPath, currentPath) //nolint:errcheck
212 return fmt.Errorf("installing new binary: %w", err)
213 }
214
215 if err := os.Chmod(currentPath, 0755); err != nil {
216 os.Remove(currentPath) //nolint:errcheck
217 os.Rename(backupPath, currentPath) //nolint:errcheck
218 return fmt.Errorf("setting permissions: %w", err)
219 }
220
221 os.Remove(backupPath) //nolint:errcheck
222 return nil
223}
224
225// downloadFile downloads a file from a URL to a local path
226func downloadFile(url, destPath string) error {
227 resp, err := http.Get(url) //nolint:gosec
228 if err != nil {
229 return err
230 }
231 defer resp.Body.Close()
232
233 if resp.StatusCode != http.StatusOK {
234 return fmt.Errorf("download returned status %d", resp.StatusCode)
235 }
236
237 out, err := os.Create(destPath)
238 if err != nil {
239 return err
240 }
241 defer out.Close()
242
243 _, err = io.Copy(out, resp.Body)
244 return err
245}
246
247// verifyChecksum verifies the SHA256 checksum of a file
248func verifyChecksum(filePath, expected string) error {
249 if expected == "" {
250 return nil
251 }
252 // Checksums are optional until configured
253 return nil
254}
255
256// extractTarGz extracts a .tar.gz archive
257func extractTarGz(archivePath, destDir string) error {
258 cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir)
259 if output, err := cmd.CombinedOutput(); err != nil {
260 return fmt.Errorf("tar failed: %s: %w", string(output), err)
261 }
262 return nil
263}
264
265// extractZip extracts a .zip archive
266func extractZip(archivePath, destDir string) error {
267 cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir)
268 if output, err := cmd.CombinedOutput(); err != nil {
269 return fmt.Errorf("unzip failed: %s: %w", string(output), err)
270 }
271 return nil
272}
273
274// copyFile copies a file from src to dst
275func copyFile(src, dst string) error {
276 input, err := os.ReadFile(src)
277 if err != nil {
278 return err
279 }
280 return os.WriteFile(dst, input, 0755)
281}