Files
felhom-controller/controller/internal/agentapi/hostpoweroff.go
T
admin 201f936e9a
gates / gates (push) Successful in 48s
v0.310.0: „A gép kikapcsolása" — the household turns the whole machine off (R-998); honest restart text (R-999)
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.
2026-10-13 20:17:30 +02:00

91 lines
3.1 KiB
Go

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
}