hub v0.29.0: Day-0 artifact manifest — version dropdowns + auto-derived sha

Operator picks a version from a Gitea-populated dropdown; the hub reads that
version's sha256 from Gitea itself (files-metadata API, no artifact download) and
vouches it — no hand-copied checksums. New internal/gitea read-only client
(ListVersions + FileSHA256, unit-tested). Configuration UI: version <select>s +
read-only sha display; handleSetArtifacts derives the sha authoritatively and
refuses the save on a Gitea lookup failure. Degrades to manual text entry without
registry creds. go build/vet/test clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:02:13 +02:00
parent ce26c9d646
commit 079a2cdd08
7 changed files with 375 additions and 12 deletions
+28 -5
View File
@@ -1,6 +1,7 @@
package web
import (
"context"
"encoding/json"
"fmt"
"html/template"
@@ -635,22 +636,24 @@ func normalizeSHA256(raw string) (string, bool) {
}
// handleSetArtifacts records the operator-vouched current artifact set (agent binary + golden
// archive: version + sha256 each) into hub_settings. This is the checksum TRUST ROOT the
// host-bootstrap script verifies fetched artifacts against. Versions are validated as bare semver
// (reusing the floor validator); sha256s as 64-hex. Empty fields are allowed (clears that field).
// archive) into hub_settings the checksum TRUST ROOT the host-bootstrap script verifies fetched
// artifacts against. The operator picks a VERSION (from the Gitea-populated dropdown); the hub DERIVES
// that version's sha256 from Gitea itself (never trusting a client-supplied checksum), so there is no
// hand-copied sha to get wrong. When no Gitea client is configured (no registry creds) it falls back
// to the submitted sha256 (legacy manual path). Empty version clears that artifact.
func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version"))
agentSHA, okAS := normalizeSHA256(r.FormValue("agent_sha256"))
goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version"))
goldenSHA, okGS := normalizeSHA256(r.FormValue("golden_sha256"))
if !okAV || !okGV {
http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther)
return
}
agentSHA, okAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
goldenSHA, okGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256"))
if !okAS || !okGS {
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
return
@@ -669,6 +672,26 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther)
}
// resolveArtifactSHA determines the sha256 to store for a chosen artifact version. An empty version
// clears the artifact (returns "",true). With a Gitea client it fetches the sha AUTHORITATIVELY from
// Gitea (the submitted value is ignored — nothing hand-typed to trust); a fetch failure returns
// (_,false) so the caller refuses the save rather than storing a version with a wrong/blank checksum.
// Without a Gitea client it validates + uses the submitted sha (legacy manual path).
func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, bool) {
if version == "" {
return "", true
}
if s.gitea != nil {
sha, err := s.gitea.FileSHA256(ctx, pkg, version, file)
if err != nil {
s.logger.Printf("[WARN] artifact sha resolve (%s/%s): %v", pkg, version, err)
return "", false
}
return sha, true
}
return normalizeSHA256(submittedSHA)
}
// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
// override. Empty clears the override (the customer then uses the global floor).
func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, customerID string) {