internal/server/api/upgrade.go
Ref: Size: 4.4 KiB History
package api
import (
"database/sql"
"errors"
"fmt"
"net/http"
"github.com/a73x/eitri/internal/server/release"
"github.com/a73x/eitri/internal/version"
)
// upgradeAgentPath is the endpoint that offers one host's agent an upgrade —
// the route the console's upgrade button calls, and the fix every "this agent
// is too old" refusal names.
func upgradeAgentPath(hostID string) string { return "/api/v1/hosts/" + hostID + "/upgrade-agent" }
// refuseBelowFloor is the admission shape every version-floored feature
// shares: only a host whose agent has proven it carries f may be handed work
// that needs f. It writes the 409 and returns true when the caller must stop.
//
// Only a connected, reporting host has told this server what it runs. The
// registry is in-memory and filled by the agent's Hello, so every host in the
// fleet is briefly silent after a server restart, and a host that has gone
// quiet holds whatever it last said. judgeOffline says what that silence
// means. false: the host takes the request as desired state and a newer agent
// may pick it up (certified keys — vmssh refuses the dial later; UDP — the
// grant reads published-and-silent). true: silence refuses, because there is
// no downstream net — an old agent ignoring the field would put user data
// somewhere it was not asked to.
//
// The three readings — not reporting, reporting no version, reporting a
// version below the floor — differ only in what is known, so they share the
// consequence (f.Consequence, omitted when the feature names none) and the
// fix.
func (a *API) refuseBelowFloor(w http.ResponseWriter, hostID string, f release.Feature, judgeOffline bool) bool {
hs, ok := a.reg.Get(hostID)
spoken := ok && hs.Online
if !spoken && !judgeOffline {
return false
}
if spoken && f.SupportedBy(hs.AgentVersion) {
return false
}
hostName := hostID
if h, err := a.st.GetHost(hostID); err == nil {
hostName = h.Name
}
var known string
switch {
case !spoken:
known = fmt.Sprintf("host %s (%s) is not reporting, so nothing says its agent carries %s (%s)",
hostName, hostID, f.Name, f.Since)
case hs.AgentVersion == "":
known = fmt.Sprintf("host %s (%s) has reported no agent version, so nothing says it is new enough for %s (%s)",
hostName, hostID, f.Name, f.Since)
default:
known = fmt.Sprintf("host %s (%s) runs agent %s, which predates %s (%s)",
hostName, hostID, hs.AgentVersion, f.Name, f.Since)
}
if f.Consequence != "" {
known += ": " + f.Consequence
}
http.Error(w, known+". Upgrade that host's agent — the console's upgrade button, or POST "+
a.URL(upgradeAgentPath(hostID))+" — then retry.", http.StatusConflict)
return true
}
// handleUpgradeAgent records a pending self-upgrade offer for one host's agent
// and pokes its snapshot stream. The human is the rollout controller: nothing
// upgrades without this per-host click, so a bad release stops at one host.
func (a *API) handleUpgradeAgent(w http.ResponseWriter, r *http.Request) {
if a.release == nil || a.upgrader == nil {
http.Error(w, "release discovery not configured", http.StatusServiceUnavailable)
return
}
m, ok := a.release.Latest()
if !ok {
http.Error(w, "release manifest not fetched yet", http.StatusServiceUnavailable)
return
}
id := r.PathValue("id")
h, err := a.st.GetHost(id)
switch {
case errors.Is(err, sql.ErrNoRows):
http.Error(w, "host not found", http.StatusNotFound)
return
case err != nil:
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Ownership gate: a foreign-tenant host answers exactly like a missing one.
if !mayActAs(principalFromContext(r), h.Tenant) {
http.Error(w, "host not found", http.StatusNotFound)
return
}
st, okReg := a.reg.Get(id)
if !okReg || !st.Online {
http.Error(w, "host is offline", http.StatusConflict)
return
}
if st.AgentVersion == "" || !version.Less(st.AgentVersion, m.Version) {
http.Error(w, "agent is not behind the latest release", http.StatusConflict)
return
}
art, ok := m.Artifacts["eitri-agent"][h.OS+"/"+h.Arch]
if !ok {
http.Error(w, "no eitri-agent artifact for "+h.OS+"/"+h.Arch, http.StatusConflict)
return
}
a.upgrader.OfferAgentUpgrade(id, m.Version, art.URL, art.SHA256)
a.hub.Poke(id)
a.audit(h.Tenant, "host.agent.upgrade", map[string]string{
"host_id": id, "remote": clientIP(r),
"from": st.AgentVersion, "to": m.Version,
})
w.WriteHeader(http.StatusAccepted)
}