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:
@@ -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) {
|
||||
|
||||
@@ -17,10 +17,27 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Generic-package names + the artifact filename inside each version (used to resolve version lists +
|
||||
// sha256 from Gitea for the Day-0 artifact manifest UI).
|
||||
const (
|
||||
pkgAgent = "felhom-agent"
|
||||
fileAgent = "felhom-agent"
|
||||
pkgGolden = "felhom-golden"
|
||||
fileGolden = "golden.tar.zst"
|
||||
)
|
||||
|
||||
// artifactChoice is one selectable artifact version + its Gitea-resolved sha256 (for the dropdown +
|
||||
// the read-only sha display).
|
||||
type artifactChoice struct {
|
||||
Version string
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// hubSession holds per-session auth and CSRF data.
|
||||
type hubSession struct {
|
||||
expiresAt time.Time
|
||||
@@ -39,6 +56,7 @@ type Server struct {
|
||||
versionChecker *VersionChecker
|
||||
templateFetcher *TemplateFetcher
|
||||
assetsMgr *assets.Manager
|
||||
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
|
||||
|
||||
sessions map[string]*hubSession
|
||||
sessionsMu sync.RWMutex
|
||||
@@ -125,6 +143,42 @@ func (s *Server) SetAssetManager(am *assets.Manager) {
|
||||
s.assetsMgr = am
|
||||
}
|
||||
|
||||
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
|
||||
// degrades to manual text entry.
|
||||
func (s *Server) SetGiteaClient(c *gitea.Client) {
|
||||
s.gitea = c
|
||||
}
|
||||
|
||||
// artifactChoices resolves the currently-available versions of a generic package + each one's sha256
|
||||
// from Gitea, newest first, for the Day-0 artifact dropdown. Returns nil (→ manual text-entry
|
||||
// fallback) when no Gitea client is configured or Gitea is unreachable. A failed sha lookup for a
|
||||
// single version drops just that version, not the whole list.
|
||||
func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifactChoice {
|
||||
if s.gitea == nil {
|
||||
return nil
|
||||
}
|
||||
vers, err := s.gitea.ListVersions(ctx, pkg)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] artifact versions (%s): %v", pkg, err)
|
||||
return nil
|
||||
}
|
||||
const maxChoices = 20
|
||||
if len(vers) > maxChoices {
|
||||
s.logger.Printf("[INFO] artifact versions (%s): showing newest %d of %d", pkg, maxChoices, len(vers))
|
||||
vers = vers[:maxChoices]
|
||||
}
|
||||
out := make([]artifactChoice, 0, len(vers))
|
||||
for _, v := range vers {
|
||||
sha, err := s.gitea.FileSHA256(ctx, pkg, v, file)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] artifact sha (%s/%s): %v", pkg, v, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, artifactChoice{Version: v, SHA256: sha})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ServeHTTP routes web requests.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
@@ -586,6 +640,7 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
data := map[string]interface{}{
|
||||
"CSRFToken": csrfToken,
|
||||
"CSRFField": s.csrfField(r),
|
||||
@@ -593,6 +648,8 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
"AssetLastSync": assetLastSync,
|
||||
"GlobalFloor": s.store.GetGlobalMinControllerVersion(),
|
||||
"Artifacts": s.store.GetArtifactManifest(),
|
||||
"AgentChoices": s.artifactChoices(ctx, pkgAgent, fileAgent),
|
||||
"GoldenChoices": s.artifactChoices(ctx, pkgGolden, fileGolden),
|
||||
"Flash": r.URL.Query().Get("flash"),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="flash flash-error">Invalid artifact version — use X.Y.Z (or blank to clear).</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "artifact_sha_invalid"}}
|
||||
<div class="flash flash-error">Invalid sha256 — use 64 hex chars (or blank to clear).</div>
|
||||
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Phase 2 managed updates: global controller-version floor (moved from the Customers page —
|
||||
@@ -71,20 +71,49 @@
|
||||
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
||||
The current agent binary + golden archive the host-bootstrap script fetches from Gitea and
|
||||
verifies (sha256) before installing. The hub vouches for these checksums (a different trust
|
||||
root than Gitea). Paste the version + sha256 printed by <code>publish-agent.sh</code> /
|
||||
<code>build-golden.sh</code>. Blank a field to clear it.
|
||||
root than Gitea). Pick a version — the sha256 is read from Gitea automatically (no manual
|
||||
copy). Choose <em>— none —</em> to clear an artifact.
|
||||
</p>
|
||||
<form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 8em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
|
||||
<form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 12em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
|
||||
{{.CSRFField}}
|
||||
<label style="font-size: 0.9em; color: #cbd5e1;">Agent</label>
|
||||
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.43.0" style="padding: 0.3em 0.5em;">
|
||||
<input type="text" name="agent_sha256" value="{{.Artifacts.AgentSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
|
||||
{{if .AgentChoices}}
|
||||
<select name="agent_version" id="agent_version" onchange="syncArtifactSha('agent')" style="padding: 0.3em 0.5em;">
|
||||
<option value="" data-sha="">— none —</option>
|
||||
{{range .AgentChoices}}
|
||||
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.AgentVersion}}selected{{end}}>{{.Version}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{else}}
|
||||
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.52.0" style="padding: 0.3em 0.5em;">
|
||||
{{end}}
|
||||
<input type="text" name="agent_sha256" id="agent_sha256" value="{{.Artifacts.AgentSHA256}}" {{if .AgentChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .AgentChoices}}opacity: 0.7;{{end}}">
|
||||
<label style="font-size: 0.9em; color: #cbd5e1;">Golden</label>
|
||||
{{if .GoldenChoices}}
|
||||
<select name="golden_version" id="golden_version" onchange="syncArtifactSha('golden')" style="padding: 0.3em 0.5em;">
|
||||
<option value="" data-sha="">— none —</option>
|
||||
{{range .GoldenChoices}}
|
||||
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.GoldenVersion}}selected{{end}}>{{.Version}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{else}}
|
||||
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
|
||||
<input type="text" name="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace;">
|
||||
{{end}}
|
||||
<input type="text" name="golden_sha256" id="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" {{if .GoldenChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .GoldenChoices}}opacity: 0.7;{{end}}">
|
||||
<span></span><span></span>
|
||||
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
|
||||
</form>
|
||||
<script>
|
||||
// When a version is picked, mirror that option's Gitea-resolved sha256 into the read-only
|
||||
// display field. The hub re-derives the sha authoritatively on save regardless of this value.
|
||||
function syncArtifactSha(kind) {
|
||||
var sel = document.getElementById(kind + '_version');
|
||||
var sha = document.getElementById(kind + '_sha256');
|
||||
if (!sel || !sha) return;
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
sha.value = (opt && opt.getAttribute('data-sha')) || '';
|
||||
}
|
||||
</script>
|
||||
</section>
|
||||
|
||||
<!-- Assets section -->
|
||||
|
||||
Reference in New Issue
Block a user