201f936e9a
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.
91 lines
3.1 KiB
Go
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
|
|
}
|