Files
homarr/tools/homarr-dev/internal/registry/ghcr.go
T

166 lines
4.7 KiB
Go

// Package registry queries GHCR for the pull-request test images Homarr's CI
// publishes.
package registry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// TestImageRepository is where CI pushes one image per open pull request.
const TestImageRepository = "homarr-labs/homarr-test"
// ImageReference returns the GHCR reference for a pull request's test image.
func ImageReference(pr int) string {
return fmt.Sprintf("ghcr.io/%s:pr-%d", TestImageRepository, pr)
}
const (
tagCacheTTL = 2 * time.Minute
httpTimeout = 20 * time.Second
)
var tagCache struct {
sync.Mutex
tags map[string]bool
fetchedAt time.Time
}
var client = &http.Client{Timeout: httpTimeout}
// PublishedTags lists every tag in the test image repository in a single
// request. Checking availability one pull request at a time with `docker
// manifest inspect` costs one network round trip per row, which dominates
// startup on a busy repository; the registry's own tag listing answers the same
// question for every row at once.
func PublishedTags(ctx context.Context, refresh bool) (map[string]bool, error) {
tagCache.Lock()
cached, fetchedAt := tagCache.tags, tagCache.fetchedAt
tagCache.Unlock()
if !refresh && cached != nil && time.Since(fetchedAt) < tagCacheTTL {
return cached, nil
}
token, err := anonymousToken(ctx)
if err != nil {
return nil, err
}
tags, err := listTags(ctx, token)
if err != nil {
if cached != nil {
return cached, err
}
return nil, err
}
tagCache.Lock()
tagCache.tags, tagCache.fetchedAt = tags, time.Now()
tagCache.Unlock()
return tags, nil
}
// HasPRImage reports whether CI has published an image for a pull request.
func HasPRImage(tags map[string]bool, pr int) bool {
return tags["pr-"+strconv.Itoa(pr)]
}
func anonymousToken(ctx context.Context) (string, error) {
url := fmt.Sprintf("https://ghcr.io/token?scope=repository:%s:pull&service=ghcr.io", TestImageRepository)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("request GHCR token: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("request GHCR token: %s", response.Status)
}
var payload struct {
Token string `json:"token"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("decode GHCR token: %w", err)
}
return payload.Token, nil
}
func listTags(ctx context.Context, token string) (map[string]bool, error) {
tags := make(map[string]bool)
next := fmt.Sprintf("https://ghcr.io/v2/%s/tags/list?n=1000", TestImageRepository)
for page := 0; next != "" && page < 20; page++ {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+token)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("list GHCR tags: %w", err)
}
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
_ = response.Body.Close()
return nil, fmt.Errorf("list GHCR tags: %s: %s", response.Status, strings.TrimSpace(string(body)))
}
var payload struct {
Tags []string `json:"tags"`
}
err = json.NewDecoder(response.Body).Decode(&payload)
linkHeader := response.Header.Get("Link")
_ = response.Body.Close()
if err != nil {
return nil, fmt.Errorf("decode GHCR tags: %w", err)
}
for _, tag := range payload.Tags {
tags[tag] = true
}
next = nextPageURL(linkHeader)
}
return tags, nil
}
// nextPageURL follows the RFC 5988 Link header the registry uses for paging.
func nextPageURL(link string) string {
for _, part := range strings.Split(link, ",") {
if !strings.Contains(part, `rel="next"`) {
continue
}
start := strings.Index(part, "<")
end := strings.Index(part, ">")
if start < 0 || end <= start {
continue
}
target := part[start+1 : end]
if strings.HasPrefix(target, "/") {
return "https://ghcr.io" + target
}
return target
}
return ""
}
// ManifestExists falls back to the Docker CLI for a single reference, which
// also works for private repositories where the anonymous listing is refused.
func ManifestExists(ctx context.Context, reference string) (bool, error) {
out, err := exec.CommandContext(ctx, "docker", "manifest", "inspect", reference).CombinedOutput()
if err == nil {
return true, nil
}
message := strings.ToLower(string(out))
if strings.Contains(message, "manifest unknown") || strings.Contains(message, "no such manifest") {
return false, nil
}
return false, fmt.Errorf("check %s: %w: %s", reference, err, strings.TrimSpace(string(out)))
}