Files
felhom.eu/hub/internal/gitea/gitea.go
T
admin b55fc17d82 hub v0.102.0 — refuse to vouch a version that cannot be installed (R-273)
The guard owed since Friday morning. Agent v0.128.0 was published as a package
and never git-tagged; it was vouched here; and because felhom-host-install.sh
fetches an agent's configs 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. handleSetArtifacts is the sole UI path to SetArtifactManifest, so the check
belongs here and nowhere else.

TWO LEGS, because both failed inside two days: the TAG (missing, R-273) and the
PACKAGE (pruned from under a still-tagged version, R-287). Either alone catches
one of them.

It asserts configs/felhom-mkfs-guarded.sh -- the FIRST of the installer's sixteen
fetch_raw calls and literally the file whose 404 broke Friday. A test pins the
constant, because probing a path that merely exists is how it stayed invisible.
The golden gets the package leg only: it has no config tree, so a tag probe would
assert something the installer never does.

"Could not verify" refuses too, with its own message. No override -- the registry
is the operator's own server, so if it is unreachable the vouch can wait.

ORDERING IS LOAD-BEARING AND A FAILING TEST FOUND IT. The probes run before
resolveArtifactSHA, whose flash conflates "missing", "unreachable" and "bad sha".
Probing first means an unreachable registry is reported as unreachable.

Five scenarios each naming the wrong outcome; three red-proofs, mutations asserted
applied and reverted. With the tag check removed, scenario A reports artifacts_set
-- Friday's exact defect returns.
2026-08-09 19:13:45 +02:00

214 lines
8.1 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
}
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)}
}
}