ananke/cmd/ananke/power_safety.go

128 lines
3.8 KiB
Go

package main
import (
"context"
"fmt"
"math"
"strings"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/service"
"scm.bstein.dev/bstein/ananke/internal/ups"
)
// buildUPSTargets runs one orchestration or CLI step.
// Signature: buildUPSTargets(cfg config.Config) ([]service.Target, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func buildUPSTargets(cfg config.Config) ([]service.Target, error) {
targets := make([]service.Target, 0, len(cfg.UPS.Targets)+1)
switch cfg.UPS.Provider {
case "nut":
if len(cfg.UPS.Targets) == 0 {
target := cfg.UPS.Target
if target == "" {
return nil, fmt.Errorf("ups target must be set")
}
targets = append(targets, service.Target{
Name: "primary",
Target: target,
Provider: ups.NewNUTProvider(target),
})
} else {
for idx, t := range cfg.UPS.Targets {
name := t.Name
if name == "" {
name = fmt.Sprintf("target-%d", idx+1)
}
targets = append(targets, service.Target{
Name: name,
Target: t.Target,
Provider: ups.NewNUTProvider(t.Target),
})
}
}
default:
return nil, fmt.Errorf("unsupported UPS provider: %s", cfg.UPS.Provider)
}
return targets, nil
}
// ensureStartupPowerSafe runs one orchestration or CLI step.
// Signature: ensureStartupPowerSafe(ctx context.Context, targets []service.Target, minimumBatteryPercent float64) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func ensureStartupPowerSafe(ctx context.Context, targets []service.Target, minimumBatteryPercent float64) error {
type targetState struct {
seenGood bool
lastErr error
}
states := make(map[string]*targetState, len(targets))
for _, t := range targets {
key := t.Name + "|" + t.Target
states[key] = &targetState{}
}
const pollInterval = 3 * time.Second
for {
onBatteryTargets := []string{}
lowChargeTargets := []string{}
allSeen := true
for _, t := range targets {
key := t.Name + "|" + t.Target
st := states[key]
sample, err := t.Provider.Read(ctx)
if err != nil {
st.lastErr = err
if !st.seenGood {
allSeen = false
}
continue
}
st.seenGood = true
st.lastErr = nil
if sample.OnBattery {
onBatteryTargets = append(onBatteryTargets, fmt.Sprintf("%s(status=%s runtime_s=%d)", t.Name, sample.RawStatus, sample.RuntimeSeconds))
}
if minimumBatteryPercent > 0 && sample.BatteryCharge > 0 && sample.BatteryCharge < minimumBatteryPercent {
lowChargeTargets = append(
lowChargeTargets,
fmt.Sprintf(
"%s(charge=%.1f%%<%.1f%% status=%s)",
t.Name,
sample.BatteryCharge,
minimumBatteryPercent,
sample.RawStatus,
),
)
}
}
if len(onBatteryTargets) > 0 {
return fmt.Errorf("startup blocked: UPS is on battery for %s", strings.Join(onBatteryTargets, ", "))
}
if len(lowChargeTargets) > 0 {
return fmt.Errorf("startup blocked: UPS battery charge below minimum for %s", strings.Join(lowChargeTargets, ", "))
}
if allSeen {
return nil
}
select {
case <-ctx.Done():
unverified := make([]string, 0, len(targets))
for _, t := range targets {
key := t.Name + "|" + t.Target
st := states[key]
if st.seenGood {
continue
}
if st.lastErr != nil {
unverified = append(unverified, fmt.Sprintf("%s(%s): %v", t.Name, t.Target, st.lastErr))
} else {
unverified = append(unverified, fmt.Sprintf("%s(%s): no telemetry sample yet", t.Name, t.Target))
}
}
roundedMin := math.Round(minimumBatteryPercent*10) / 10
return fmt.Errorf("startup blocked: unable to verify UPS telemetry before timeout (minimum_battery_percent=%.1f): %s", roundedMin, strings.Join(unverified, " | "))
case <-time.After(pollInterval):
}
}
}