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,5 +1,29 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.29.0 — Day-0 artifact manifest: version dropdowns + auto-derived sha (2026-07-01)
|
||||
|
||||
Removes the hand-copied sha256 from the Day-0 artifact manifest. The operator now **picks a version**
|
||||
from a dropdown of what's actually in Gitea (olders get pruned), and the hub **reads that version's
|
||||
sha256 from Gitea itself** — no transcription, no stale checksums. Keeps the human-in-the-loop trust
|
||||
gate (the operator still deliberately chooses the version; "latest" is never auto-promoted) while the
|
||||
hub stays the checksum trust root.
|
||||
|
||||
- **`internal/gitea`** (new): a minimal read-only Gitea packages client — `ListVersions` (generic
|
||||
package versions, newest-semver first) + `FileSHA256` (a version's file sha256 via the files-metadata
|
||||
API, **without downloading** the artifact — important for the ~GB golden). Basic-auth with the
|
||||
registry creds the hub already holds. Unit-tested against an httptest server (filter+sort, preferred
|
||||
file match + fallback, non-200 → error).
|
||||
- **Configuration → Day-0 artifacts:** the two version text inputs are now `<select>` dropdowns
|
||||
populated from Gitea; the sha256 fields are **read-only, displayed** (mirrored from the picked
|
||||
version via a tiny inline script). Choosing "— none —" clears an artifact.
|
||||
- **`handleSetArtifacts`:** derives each chosen version's sha256 from Gitea **authoritatively** (a
|
||||
client-submitted sha is ignored); a Gitea lookup failure REFUSES the save (never stores a version
|
||||
with a wrong/blank checksum) rather than corrupting the manifest.
|
||||
- **Graceful degradation:** with no registry creds (`web.SetGiteaClient` not wired) the form falls back
|
||||
to the previous manual text-entry path. `main.go` enables the Gitea browser when `REGISTRY_USERNAME`
|
||||
/`REGISTRY_TOKEN` are set.
|
||||
- `go build`/`vet`/`test ./...` clean.
|
||||
|
||||
## v0.28.0 — global settings → Configuration tab + online setup command (2026-07-01)
|
||||
|
||||
Three operator-requested improvements (companion: host-install script v1.2.0).
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
||||
@@ -254,6 +255,13 @@ func main() {
|
||||
webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger)
|
||||
webServer.SetTemplateFetcher(templateFetcher)
|
||||
webServer.SetAssetManager(assetsMgr)
|
||||
// Day-0 artifact version dropdowns: let the operator pick a version and have the hub derive the
|
||||
// sha256 from Gitea (no hand-copied checksums). Reuses the registry creds; degrades to manual text
|
||||
// entry when they're absent.
|
||||
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
|
||||
webServer.SetGiteaClient(gitea.New("https://gitea.dooplex.hu", "admin", cfg.Registry.Username, cfg.Registry.Token))
|
||||
logger.Printf("[INFO] Gitea artifact browser enabled (Day-0 version dropdowns)")
|
||||
}
|
||||
|
||||
// Build HTTP mux
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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"
|
||||
"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.
|
||||
func New(baseURL, owner, user, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
owner: owner,
|
||||
user: user,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ListVersions filters to the requested package name and returns versions newest-semver first.
|
||||
func TestListVersions_FilterAndSort(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/packages/admin") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Intentionally unsorted + a foreign package that must be filtered out.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`[
|
||||
{"name":"felhom-agent","version":"0.43.0","type":"generic"},
|
||||
{"name":"felhom-agent","version":"0.52.0","type":"generic"},
|
||||
{"name":"felhom-golden","version":"0.85.1","type":"generic"},
|
||||
{"name":"felhom-agent","version":"0.9.0","type":"generic"}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "admin", "u", "t")
|
||||
vers, err := c.ListVersions(context.Background(), "felhom-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ListVersions: %v", err)
|
||||
}
|
||||
// Only felhom-agent, newest semver first (0.52.0 > 0.43.0 > 0.9.0 — numeric, not lexical).
|
||||
want := []string{"0.52.0", "0.43.0", "0.9.0"}
|
||||
if len(vers) != len(want) {
|
||||
t.Fatalf("got %v, want %v", vers, want)
|
||||
}
|
||||
for i := range want {
|
||||
if vers[i] != want[i] {
|
||||
t.Fatalf("got %v, want %v", vers, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FileSHA256 prefers an exact filename match; otherwise it falls back to the first file.
|
||||
func TestFileSHA256_PreferredAndFallback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/felhom-golden/0.85.1/files"):
|
||||
w.Write([]byte(`[
|
||||
{"name":"stray.txt","sha256":"aaaa"},
|
||||
{"name":"golden.tar.zst","sha256":"f87031cc"}
|
||||
]`))
|
||||
case strings.Contains(r.URL.Path, "/felhom-agent/0.52.0/files"):
|
||||
w.Write([]byte(`[{"name":"felhom-agent","sha256":"5bfc690c"}]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(srv.URL, "admin", "u", "t")
|
||||
|
||||
// preferred-file match among multiple files
|
||||
sha, err := c.FileSHA256(context.Background(), "felhom-golden", "0.85.1", "golden.tar.zst")
|
||||
if err != nil || sha != "f87031cc" {
|
||||
t.Fatalf("golden sha: got %q err %v, want f87031cc", sha, err)
|
||||
}
|
||||
// single-file version resolves regardless of preferred name (fallback to first)
|
||||
sha, err = c.FileSHA256(context.Background(), "felhom-agent", "0.52.0", "felhom-agent")
|
||||
if err != nil || sha != "5bfc690c" {
|
||||
t.Fatalf("agent sha: got %q err %v, want 5bfc690c", sha, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-200 from Gitea surfaces as an error (so a save refuses rather than storing a blank sha).
|
||||
func TestGetJSON_ErrorOnNon200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "admin", "u", "t")
|
||||
if _, err := c.ListVersions(context.Background(), "felhom-agent"); err == nil {
|
||||
t.Fatal("expected an error on HTTP 500")
|
||||
}
|
||||
}
|
||||
@@ -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