Files
felhom.eu/hub/internal/gitea/gitea.go
T

234 lines
8.8 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"
"io"
"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
}
// ExactFileSHA256 returns the sha256 of exactly `file` in the package version, or ("", nil) when the version has no
// such file. Unlike FileSHA256 it NEVER falls back to another file: the config bundle (R-840) must not inherit the
// binary's sha on a version that carries no bundle.
func (c *Client) ExactFileSHA256(ctx context.Context, pkgName, version, file 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
}
for _, f := range files {
if f.Name == file {
if f.SHA256 == "" {
return "", fmt.Errorf("no sha256 for %s/%s file %q", pkgName, version, file)
}
return f.SHA256, nil
}
}
return "", 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
}
// --- Installability probes (R-273) --------------------------------------------------------------
//
// WHY THESE EXIST. On 2026-08-08 agent v0.128.0 was published as a package and never git-tagged;
// the hub vouched it, and because felhom-host-install.sh fetches an agent's config files from
// `raw/tag/v<version>/configs/`, EVERY fresh install and reinstall died at step 5 of 8, as root, on
// a virgin machine, for most of a day. Nothing checked at the moment of risk. These two probes are
// what the artifact-manifest save now runs before it writes.
//
// They deliberately answer three ways — yes / no / could-not-tell — because "could not verify" and
// "is missing" must not collapse into one another at the save boundary.
// ProbeResult is a three-valued answer. Err non-nil means UNDETERMINED: the caller must refuse the
// save with a "could not verify" message rather than with a "missing" one.
type ProbeResult struct {
OK bool
Err error
}
// TagServesFile reports whether `raw/tag/<tag>/<path>` resolves — i.e. whether the tag exists AND
// its tree carries the file. Both halves matter: a tag that exists but predates the config would
// 404 a box mid-install just as thoroughly as an absent tag.
//
// The caller passes the path the INSTALLER actually fetches. Asserting some other path that merely
// happens to exist is how Friday's failure stayed invisible.
func (c *Client) TagServesFile(ctx context.Context, repo, tag, path string) ProbeResult {
url := fmt.Sprintf("%s/%s/%s/raw/tag/%s/%s", c.baseURL, c.owner, repo, tag, path)
return c.probe(ctx, url)
}
// PackageDownloadable reports whether a generic package file can actually be fetched.
func (c *Client) PackageDownloadable(ctx context.Context, pkg, version, file string) ProbeResult {
url := fmt.Sprintf("%s/api/packages/%s/generic/%s/%s/%s", c.baseURL, c.owner, pkg, version, file)
return c.probe(ctx, url)
}
// probe does one GET and maps the outcome onto the three-valued answer. A GET rather than a HEAD:
// Gitea's raw and package routes do not answer HEAD consistently, and a probe that is wrong about
// its own method would be worse than no probe. Nothing reads the body.
func (c *Client) probe(ctx context.Context, url string) ProbeResult {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ProbeResult{Err: err}
}
if c.token != "" {
req.SetBasicAuth(c.user, c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return ProbeResult{Err: err} // transport failure -> UNDETERMINED, never "missing"
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<12))
switch {
case resp.StatusCode == http.StatusOK:
return ProbeResult{OK: true}
case resp.StatusCode == http.StatusNotFound:
return ProbeResult{OK: false} // a real, determined "it is not there"
default:
// 5xx, 401, 403, a proxy error: the registry did not tell us the thing is absent, it told us
// it could not answer. Undetermined.
return ProbeResult{Err: fmt.Errorf("registry returned HTTP %d for %s", resp.StatusCode, url)}
}
}