e348c4ef6e
gates / gates (push) Successful in 28s
Third and last leg, found the same way as the second — by not accepting that the numbers matched the arithmetic when they did not. After the fan-out and the side-by-side resolve the page was ~11.9s mean where ~3s was predicted. Cause: the client used http.DefaultTransport, whose MaxIdleConnsPerHost is 2. Above that Go opens a connection per request and discards it after, so under a 16-way fan-out almost every call paid a fresh TCP setup AND a fresh authentication. Authentication is the expensive half: unauthenticated /api/v1/version answers in ~0.03s while an authenticated package call takes ~0.24s against the same Gitea instance. Transport sized to the fan-out: MaxIdleConnsPerHost 16, MaxConnsPerHost 16 as a ceiling so a large package list can never stampede Gitea harder than the fan-out needs, IdleConnTimeout 90s.
149 lines
5.0 KiB
Go
149 lines
5.0 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.
|
||
//
|
||
// ⚠ IT DOES NOT USE http.DefaultTransport, and that is the point (v0.100.2). The Configuration page
|
||
// resolves up to 40 package-metadata calls against this one host, now concurrently — and the default
|
||
// transport allows **MaxIdleConnsPerHost: 2**. Above that it opens a connection per request and
|
||
// throws it away afterwards, so almost every call paid a fresh TCP setup AND a fresh
|
||
// authentication, which is the expensive part: an unauthenticated `/api/v1/version` answers in
|
||
// ~0.03 s while an authenticated package call takes ~0.24 s against the same Gitea.
|
||
//
|
||
// Sizing it to the fan-out (16 = 8 in flight × the 2 dropdowns resolved side by side) lets the
|
||
// connections be reused for the whole page instead of churned.
|
||
func New(baseURL, owner, user, token string) *Client {
|
||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||
tr.MaxIdleConnsPerHost = 16
|
||
tr.MaxConnsPerHost = 16 // a ceiling too: never stampede Gitea harder than the fan-out needs
|
||
tr.IdleConnTimeout = 90 * time.Second
|
||
return &Client{
|
||
baseURL: strings.TrimRight(baseURL, "/"),
|
||
owner: owner,
|
||
user: user,
|
||
token: token,
|
||
http: &http.Client{Timeout: 10 * time.Second, Transport: tr},
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|