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.
This commit is contained in:
2026-08-09 19:13:45 +02:00
parent 6088afcbed
commit b55fc17d82
5 changed files with 428 additions and 0 deletions
+65
View File
@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
@@ -146,3 +147,67 @@ func compareSemver(a, b string) int {
}
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)}
}
}