Files
felhom.eu/hub/internal/poke/notifier.go
T
admin eb227486d0 hub v0.59.0: Direction-2a agent-plane immediate-sync poke sender + ep0 felhom-poke surface
- internal/poke: pinned-host-key SSH poke sender (wgsync sibling) + fire-and-forget Notifier (PokeHost/PokeAllHosts). Poke refuses non-WG targets pre-dial; contentless via ep0 forced command to the box WG /32:51822.
- wiring: Server.SetPoke; applyPBSDR pokes the host after each descriptor gen-bump; handleSetArtifacts (MinAgent floor) pokes all hosts. main.go env POKE_SSH_KEY_FILE (reuses peersync endpoint/hostkey).
- scripts/felhom-poke.sh (non-root forced command) + offsite-endpoint.md §11; manifests/hub.yaml Secret/agent-poke + POKE_SSH_KEY_FILE (image tag bump follows the build).
2026-07-16 22:48:15 +02:00

101 lines
3.0 KiB
Go

package poke
import (
"context"
"log"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// peerStore is the WG-peer lookup seam (satisfied by *store.Store; tests fake it).
type peerStore interface {
GetWGPeerForHost(hostID string) (*store.WGPeer, error)
ListWGPeers() ([]store.WGPeer, error)
}
// pokeSender is the SSH send seam (satisfied by *Client; tests fake it).
type pokeSender interface {
Poke(ctx context.Context, boxWGIP string) error
}
// Notifier turns an operator-intent event (a host id, or "all hosts") into fire-and-forget pokes.
// Every public method returns immediately and does the SSH work on a detached goroutine, so a poke
// NEVER blocks or fails the hub save that triggered it — a lost poke is harmless (the 15-min cycle
// reconciles). nil *Notifier is a safe no-op (poke disabled / not configured).
type Notifier struct {
store peerStore
client pokeSender
timeout time.Duration
logger *log.Logger
}
// NewNotifier builds a notifier. timeout bounds one poke (default 10s).
func NewNotifier(st peerStore, client pokeSender, logger *log.Logger) *Notifier {
if logger == nil {
logger = log.Default()
}
return &Notifier{store: st, client: client, timeout: 10 * time.Second, logger: logger}
}
// PokeHost fires a poke to one host's box (async, fire-and-forget). Safe on a nil receiver.
func (n *Notifier) PokeHost(hostID string) {
if n == nil {
return
}
go func() {
if err := n.pokeHost(context.Background(), hostID); err != nil {
n.logger.Printf("[INFO] poke: host %s not nudged (%v) — the 15-min cycle still reconciles", hostID, err)
}
}()
}
// PokeAllHosts fires a poke to EVERY host with a WG peer (async, fire-and-forget). Used by a
// fleet-wide agent-plane change (e.g. a MinAgent floor). Safe on a nil receiver.
func (n *Notifier) PokeAllHosts() {
if n == nil {
return
}
go func() {
peers, err := n.store.ListWGPeers()
if err != nil {
n.logger.Printf("[INFO] poke: fleet nudge skipped (peer list: %v) — the 15-min cycle still reconciles", err)
return
}
nudged := 0
for _, p := range peers {
if p.HostID == "" || p.AssignedIP == "" {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), n.timeout)
if err := n.client.Poke(ctx, p.AssignedIP); err != nil {
n.logger.Printf("[INFO] poke: host %s (%s) not nudged (%v)", p.HostID, p.AssignedIP, err)
} else {
nudged++
}
cancel()
}
n.logger.Printf("[INFO] poke: fleet nudge sent to %d/%d WG peers", nudged, len(peers))
}()
}
// pokeHost is the synchronous body (test seam). Resolves the host's WG /32 and sends one poke.
func (n *Notifier) pokeHost(ctx context.Context, hostID string) error {
peer, err := n.store.GetWGPeerForHost(hostID)
if err != nil {
return err
}
if peer == nil || peer.AssignedIP == "" {
return errNoPeer
}
tctx, cancel := context.WithTimeout(ctx, n.timeout)
defer cancel()
return n.client.Poke(tctx, peer.AssignedIP)
}
type pokeErr string
func (e pokeErr) Error() string { return string(e) }
const errNoPeer = pokeErr("host has no WG peer")