v0.310.0: „A gép kikapcsolása" — the household turns the whole machine off (R-998); honest restart text (R-999)
gates / gates (push) Successful in 48s

POST /api/server/poweroff (dashboard login, POST only, the typed word KIKAPCSOLÁS / TURN OFF)
→ agent POST /host/poweroff (MinAgent 0.155.0; hidden for an older agent). The controller refuses
during its own backup/restore/app update; the agent's reasons are shown as household sentences.
„Kiszolgáló újraindítása" becomes „A Felhom újraindítása" and says it restarts the Felhom part;
/api/server/reboot is POST only (R-983). Tests red-proofed; parity fixtures regenerated on purpose.
This commit is contained in:
2026-10-13 20:17:30 +02:00
parent 1b2f74c52f
commit 201f936e9a
20 changed files with 743 additions and 41 deletions
+12
View File
@@ -83,6 +83,11 @@ const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
// for two months, and it heals itself when the agent updates.
const FeatureRetainedRecoveryClass Feature = "retained_recovery_class"
// FeatureHostPowerOff is the household power-off (R-998, agent v0.155.0): POST /host/poweroff. A POST that switches
// the machine off cannot be probed, so the VERSION decides. The settings card hides only on a definite SupportNo; on
// Unknown it stays, and an old agent's 404 is answered with the update sentence.
const FeatureHostPowerOff Feature = "host_poweroff"
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
// refuses — the existing agent-error paths speak honestly when the agent is down).
type SupportState int
@@ -159,6 +164,10 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
FeatureRetainedRecoveryClass: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
// R-998: probing would switch the machine off. The row must exist (table-gap rule above).
FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
}
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
@@ -192,6 +201,9 @@ var featureMinAgent = map[Feature]string{
// screen must keep saying it cannot tell the two apart — which was true, and is what this gate
// preserves for boxes that have not updated yet.
FeatureRetainedRecoveryClass: "0.129.0",
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
FeatureHostPowerOff: "0.155.0",
}
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
@@ -0,0 +1,90 @@
package agentapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
)
// ── POST /host/poweroff (R-998, agent >= 0.155.0, felhom.eu documentation/architecture/14-host-power-off.md) ──
//
// The household's „A gép kikapcsolása". The agent answers 202 (it shuts this guest down, then the host) or 409
// with a machine-readable reason. Unlike post(), the 409 BODY matters here: the dashboard tells the household
// WHY (a backup runs, too soon after start, already asked…), so this call reads it.
// Agent refusal reasons (stable wire values; mirror felhom-agent internal/localapi/hostpoweroff.go).
const (
PowerOffTooSoon = "too_soon_after_boot"
PowerOffAlreadyAsked = "already_requested_this_boot"
PowerOffBackup = "backup_running"
PowerOffRestoreTest = "restore_test_running"
PowerOffOSUpdate = "os_update_running"
PowerOffCtrlUpdate = "controller_update_running"
PowerOffDrive = "drive_operation_running"
PowerOffEscrow = "escrow_ceremony_running"
PowerOffBusy = "host_busy"
PowerOffOtherGuests = "other_guests_running"
PowerOffUnknown = "state_unknown"
)
// PowerOffRefused is the agent's 409: Reason is one of the constants above (or a newer one), Detail is for the log.
type PowerOffRefused struct {
Reason string
Detail string
}
func (e *PowerOffRefused) Error() string {
return "agent refused the power-off: " + e.Reason + " (" + e.Detail + ")"
}
// HostPowerOff asks the agent to shut this guest down and power the host off. nil = accepted (the box goes down
// within minutes). A *PowerOffRefused = the agent said no, with its reason. A *StatusError 404 = an agent that
// predates the route.
func (c *Client) HostPowerOff(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/host/poweroff", bytes.NewReader([]byte("{}")))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("agentapi: POST /host/poweroff: %w", err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp)
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
logx.Debugf(c.logger, "[agentapi] POST /host/poweroff -> %d (%dms)", resp.StatusCode, time.Since(start).Milliseconds())
switch resp.StatusCode {
case http.StatusAccepted, http.StatusOK:
return nil
case http.StatusConflict:
var env struct {
Data struct {
Reason string `json:"reason"`
Detail string `json:"detail"`
} `json:"data"`
}
if json.Unmarshal(raw, &env) != nil || env.Data.Reason == "" {
return &PowerOffRefused{Reason: PowerOffUnknown, Detail: "unreadable 409 body"}
}
return &PowerOffRefused{Reason: env.Data.Reason, Detail: env.Data.Detail}
default:
return &StatusError{Method: http.MethodPost, Path: "/host/poweroff", Code: resp.StatusCode}
}
}
// IsPowerOffRefused returns the refusal, if err is one.
func IsPowerOffRefused(err error) (*PowerOffRefused, bool) {
var r *PowerOffRefused
ok := errors.As(err, &r)
return r, ok
}