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:
@@ -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)}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user