Files
felhom.eu/hub/internal/gitea/gitea.go
T
admin e348c4ef6e
gates / gates (push) Successful in 28s
hub: the Gitea client keeps its connections (MaxIdleConnsPerHost was 2)
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.
2026-08-08 17:55:18 +02:00

149 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}