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).
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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")
|
||||
Reference in New Issue
Block a user