a73x

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)
}