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>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ListVersions filters to the requested package name and returns versions newest-semver first.
|
||||
func TestListVersions_FilterAndSort(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/packages/admin") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Intentionally unsorted + a foreign package that must be filtered out.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`[
|
||||
{"name":"felhom-agent","version":"0.43.0","type":"generic"},
|
||||
{"name":"felhom-agent","version":"0.52.0","type":"generic"},
|
||||
{"name":"felhom-golden","version":"0.85.1","type":"generic"},
|
||||
{"name":"felhom-agent","version":"0.9.0","type":"generic"}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "admin", "u", "t")
|
||||
vers, err := c.ListVersions(context.Background(), "felhom-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ListVersions: %v", err)
|
||||
}
|
||||
// Only felhom-agent, newest semver first (0.52.0 > 0.43.0 > 0.9.0 — numeric, not lexical).
|
||||
want := []string{"0.52.0", "0.43.0", "0.9.0"}
|
||||
if len(vers) != len(want) {
|
||||
t.Fatalf("got %v, want %v", vers, want)
|
||||
}
|
||||
for i := range want {
|
||||
if vers[i] != want[i] {
|
||||
t.Fatalf("got %v, want %v", vers, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FileSHA256 prefers an exact filename match; otherwise it falls back to the first file.
|
||||
func TestFileSHA256_PreferredAndFallback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/felhom-golden/0.85.1/files"):
|
||||
w.Write([]byte(`[
|
||||
{"name":"stray.txt","sha256":"aaaa"},
|
||||
{"name":"golden.tar.zst","sha256":"f87031cc"}
|
||||
]`))
|
||||
case strings.Contains(r.URL.Path, "/felhom-agent/0.52.0/files"):
|
||||
w.Write([]byte(`[{"name":"felhom-agent","sha256":"5bfc690c"}]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "admin", "u", "t")
|
||||
|
||||
// preferred-file match among multiple files
|
||||
sha, err := c.FileSHA256(context.Background(), "felhom-golden", "0.85.1", "golden.tar.zst")
|
||||
if err != nil || sha != "f87031cc" {
|
||||
t.Fatalf("golden sha: got %q err %v, want f87031cc", sha, err)
|
||||
}
|
||||
// single-file version resolves regardless of preferred name (fallback to first)
|
||||
sha, err = c.FileSHA256(context.Background(), "felhom-agent", "0.52.0", "felhom-agent")
|
||||
if err != nil || sha != "5bfc690c" {
|
||||
t.Fatalf("agent sha: got %q err %v, want 5bfc690c", sha, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-200 from Gitea surfaces as an error (so a save refuses rather than storing a blank sha).
|
||||
func TestGetJSON_ErrorOnNon200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "admin", "u", "t")
|
||||
if _, err := c.ListVersions(context.Background(), "felhom-agent"); err == nil {
|
||||
t.Fatal("expected an error on HTTP 500")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user