Files
felhom.eu/hub/internal/gitea/gitea.go
T
admin 079a2cdd08 hub v0.29.0: Day-0 artifact manifest — version dropdowns + auto-derived sha
Operator picks a version from a Gitea-populated dropdown; the hub reads that
version's sha256 from Gitea itself (files-metadata API, no artifact download) and
vouches it — no hand-copied checksums. New internal/gitea read-only client
(ListVersions + FileSHA256, unit-tested). Configuration UI: version <select>s +
read-only sha display; handleSetArtifacts derives the sha authoritatively and
refuses the save on a Gitea lookup failure. Degrades to manual text entry without
registry creds. go build/vet/test clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:02:13 +02:00

135 lines
4.1 KiB
Go

// Package gitea is a minimal read-only client for the Gitea generic-package API. It lets the hub
// discover which artifact versions currently exist (olders get pruned) and read each file's sha256
// WITHOUT downloading the artifact — so the operator picks a version in the UI and the hub derives and
// vouches the checksum itself, instead of the operator pasting a 64-hex sha by hand.
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// Client talks to the Gitea packages API with basic auth (the registry username + token the hub
// already holds for the controller image pull).
type Client struct {
baseURL string // e.g. https://gitea.dooplex.hu (no trailing slash)
owner string // package owner, e.g. admin
user string
token string
http *http.Client
}
// New builds a client. baseURL/owner/user/token come from the hub's registry config.
func New(baseURL, owner, user, token string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
owner: owner,
user: user,
token: token,
http: &http.Client{Timeout: 10 * time.Second},
}
}
type pkgEntry struct {
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
}
// ListVersions returns the versions of a generic package, newest-semver first. Non-semver versions
// (if any) sort after the semver ones so nothing is silently dropped.
func (c *Client) ListVersions(ctx context.Context, pkgName string) ([]string, error) {
u := fmt.Sprintf("%s/api/v1/packages/%s?type=generic&q=%s&limit=100",
c.baseURL, c.owner, pkgName)
var pkgs []pkgEntry
if err := c.getJSON(ctx, u, &pkgs); err != nil {
return nil, err
}
var vers []string
for _, p := range pkgs {
if p.Name == pkgName && p.Version != "" {
vers = append(vers, p.Version)
}
}
sort.SliceStable(vers, func(i, j int) bool { return compareSemver(vers[i], vers[j]) > 0 })
return vers, nil
}
type pkgFile struct {
Name string `json:"name"`
SHA256 string `json:"sha256"`
}
// FileSHA256 returns the sha256 of a file inside a generic package version. When the version holds
// multiple files it prefers an exact match to preferredFile (e.g. "golden.tar.zst"); otherwise it uses
// the first file. This is a cheap metadata call — the artifact bytes are never downloaded.
func (c *Client) FileSHA256(ctx context.Context, pkgName, version, preferredFile string) (string, error) {
u := fmt.Sprintf("%s/api/v1/packages/%s/generic/%s/%s/files",
c.baseURL, c.owner, pkgName, version)
var files []pkgFile
if err := c.getJSON(ctx, u, &files); err != nil {
return "", err
}
if len(files) == 0 {
return "", fmt.Errorf("no files listed for %s/%s", pkgName, version)
}
if preferredFile != "" {
for _, f := range files {
if f.Name == preferredFile && f.SHA256 != "" {
return f.SHA256, nil
}
}
}
if files[0].SHA256 == "" {
return "", fmt.Errorf("no sha256 for %s/%s file %q", pkgName, version, files[0].Name)
}
return files[0].SHA256, nil
}
func (c *Client) getJSON(ctx context.Context, url string, out interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.SetBasicAuth(c.user, c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("gitea GET %s: HTTP %d", url, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// compareSemver returns >0 if a>b, 0 if equal, <0 if a<b. Falls back to a lexical compare on a
// non-"X.Y.Z" input (mirrors web.compareVersions; kept local to avoid an import cycle).
func compareSemver(a, b string) int {
a = strings.TrimPrefix(a, "v")
b = strings.TrimPrefix(b, "v")
ap := strings.SplitN(a, ".", 3)
bp := strings.SplitN(b, ".", 3)
if len(ap) != 3 || len(bp) != 3 {
return strings.Compare(a, b)
}
for i := 0; i < 3; i++ {
ai, e1 := strconv.Atoi(ap[i])
bi, e2 := strconv.Atoi(bp[i])
if e1 != nil || e2 != nil {
return strings.Compare(a, b)
}
if ai != bi {
return ai - bi
}
}
return 0
}