feat(remote): route LXC/VM checks through the Proxmox host, not direct SSH
The scheduler SSHed each guest directly and assumed a deployed probe script plus working root SSH at the guest's address — false for headless (nfs-export), keyless (teddycloud), mesh-only (rclone), and macOS (mac-mini) targets, which left 49 enabled checks stuck "down" on a healthy fleet. Extract the MCP run tool's resolveExecTarget into a shared internal/remote package and make it the single execution path for both the scheduler and MCP. LXC/VM checks now host-hop via pct exec / qm guest exec through the owning Proxmox host (no per-guest lan_ip, sshd, or authorized key needed); hosts and workstations resolve their address and user live, so mac-mini's `user: dtoro` is honored without a re-seed. Address preference now prefers public_ipv4 over mesh, so netbird-vps is probeable from the scheduler container. cpu_check.sh gains a real Darwin branch (it reported cpu_pct 0 before). checkdefaults.resolveSSHUser reads the top-level `user` attribute too. A machine-target resolution failure is now logged before falling back to baked config, so a broken probe-config is distinguishable from a real outage.
This commit is contained in:
172
internal/remote/remote_test.go
Normal file
172
internal/remote/remote_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// guestWrap and IsGuest are pure logic — always tested. The DB-backed
|
||||
// resolvers are integration tests guarded by OIKOS_TEST_DATABASE_URL, the
|
||||
// same convention as internal/scheduler/coverage_test.go.
|
||||
|
||||
func TestIsGuest(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"lxc": true, "vm": true,
|
||||
"proxmox-host": false, "workstation": false,
|
||||
"service": false, "docker-container": false,
|
||||
}
|
||||
for typ, want := range cases {
|
||||
if got := IsGuest(typ); got != want {
|
||||
t.Errorf("IsGuest(%q) = %v, want %v", typ, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestWrapLXC(t *testing.T) {
|
||||
w := guestWrap("lxc", "132")
|
||||
out := w("/opt/oikos/checks/cpu_check.sh 'svc'")
|
||||
if !strings.Contains(out, "pct exec 132 -- bash -c ") {
|
||||
t.Fatalf("lxc wrap must use pct exec: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "qm guest exec") {
|
||||
t.Fatalf("lxc wrap must not use qm: %q", out)
|
||||
}
|
||||
// The base64 payload must round-trip to the original command.
|
||||
i := strings.Index(out, "echo ")
|
||||
j := strings.LastIndex(out, " | base64 -d | bash")
|
||||
if i < 0 || j < 0 || j <= i {
|
||||
t.Fatalf("cannot locate base64 payload in %q", out)
|
||||
}
|
||||
dec, err := base64.StdEncoding.DecodeString(out[i+len("echo ") : j])
|
||||
if err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if string(dec) != "/opt/oikos/checks/cpu_check.sh 'svc'" {
|
||||
t.Fatalf("round-trip mismatch: %q", string(dec))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestWrapVM(t *testing.T) {
|
||||
w := guestWrap("vm", "108")
|
||||
out := w("uname -a")
|
||||
if !strings.Contains(out, "qm guest exec 108") {
|
||||
t.Fatalf("vm wrap must use qm guest exec: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecTargetUnsupported(t *testing.T) {
|
||||
// No DB needed: an unsupported slug prefix errors before any query.
|
||||
if _, err := ResolveExecTarget(context.Background(), nil, "service:gitea", DefaultUser); err == nil {
|
||||
t.Fatal("expected error for unsupported target prefix")
|
||||
}
|
||||
}
|
||||
|
||||
// --- integration tests (require a real Postgres) ---
|
||||
|
||||
func newRemotePool(t *testing.T) *db.Pool {
|
||||
t.Helper()
|
||||
base := testDatabaseURL(t)
|
||||
return createTestDB(t, base)
|
||||
}
|
||||
|
||||
func testDatabaseURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
u := getenvOrDefault("OIKOS_TEST_DATABASE_URL", "")
|
||||
if u == "" {
|
||||
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestResolveHostPrefersLAN(t *testing.T) {
|
||||
pool := newRemotePool(t)
|
||||
ctx := context.Background()
|
||||
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||
VALUES ($1,'host:x','proxmox-host','x','active','{"lan_ip":"10.0.0.1","public_ipv4":"1.2.3.4","mesh":{"netbird":{"ip":"100.64.0.1"}}}'::jsonb,1,now(),now())`,
|
||||
uuid.New())
|
||||
|
||||
addr, user, err := ResolveHost(ctx, pool, "host:x", DefaultUser)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveHost: %v", err)
|
||||
}
|
||||
if addr != "10.0.0.1" {
|
||||
t.Errorf("addr = %q, want lan_ip 10.0.0.1", addr)
|
||||
}
|
||||
if user != "root" {
|
||||
t.Errorf("user = %q, want root", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHostFallsBackToPublicIPv4(t *testing.T) {
|
||||
// netbird-vps: no lan_ip, has public_ipv4 + mesh ip. Must prefer
|
||||
// public_ipv4 — the scheduler container has no mesh interface.
|
||||
pool := newRemotePool(t)
|
||||
ctx := context.Background()
|
||||
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||
VALUES ($1,'host:vps','standalone-server','vps','active','{"public_ipv4":"82.165.190.79","mesh":{"netbird":{"ip":"100.122.165.149"}}}'::jsonb,1,now(),now())`,
|
||||
uuid.New())
|
||||
|
||||
addr, _, err := ResolveHost(ctx, pool, "host:vps", DefaultUser)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveHost: %v", err)
|
||||
}
|
||||
if addr != "82.165.190.79" {
|
||||
t.Errorf("addr = %q, want public_ipv4 (mesh unreachable from container)", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecTargetForCheckLXCRoutesViaHost(t *testing.T) {
|
||||
// An LXC guest with a `hosts` edge to a proxmox host must resolve to the
|
||||
// HOST's address (the host-hop target), wrapped as `pct exec`.
|
||||
pool := newRemotePool(t)
|
||||
ctx := context.Background()
|
||||
hostID := uuid.New()
|
||||
guestID := uuid.New()
|
||||
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||
VALUES ($1,'host:hubris','proxmox-host','hubris','active','{"lan_ip":"192.168.8.77"}'::jsonb,1,now(),now())`, hostID)
|
||||
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||
VALUES ($1,'lxc:rclone','lxc','rclone','active','{"pve_id":"132"}'::jsonb,1,now(),now())`, guestID)
|
||||
mustExec(t, pool, ctx, `INSERT INTO relationships (source_id, target_id, type, valid_from, created_at)
|
||||
VALUES ($1,$2,'hosts',now(),now())`, hostID, guestID)
|
||||
|
||||
et, err := ResolveExecTargetForCheck(ctx, pool, guestID, "lxc", DefaultUser)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveExecTargetForCheck: %v", err)
|
||||
}
|
||||
if et.Host != "192.168.8.77" {
|
||||
t.Errorf("Host = %q, want proxmox host lan_ip 192.168.8.77 (host-hop)", et.Host)
|
||||
}
|
||||
out := et.Wrap("/opt/oikos/checks/cpu_check.sh")
|
||||
if !strings.Contains(out, "pct exec 132") {
|
||||
t.Errorf("guest wrap must use pct exec 132, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecTargetForCheckHostIsDirect(t *testing.T) {
|
||||
// A host-like target resolves to its own address with identity wrap.
|
||||
pool := newRemotePool(t)
|
||||
ctx := context.Background()
|
||||
hid := uuid.New()
|
||||
mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||
VALUES ($1,'ws:mini','workstation','mini','active','{"lan_ip":"192.168.178.182","user":"dtoro"}'::jsonb,1,now(),now())`, hid)
|
||||
|
||||
et, err := ResolveExecTargetForCheck(ctx, pool, hid, "workstation", DefaultUser)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveExecTargetForCheck: %v", err)
|
||||
}
|
||||
if et.Host != "192.168.178.182" {
|
||||
t.Errorf("Host = %q, want 192.168.178.182", et.Host)
|
||||
}
|
||||
// Workstation's top-level `user` must be honored (the mac-mini fix).
|
||||
if et.User != "dtoro" {
|
||||
t.Errorf("User = %q, want dtoro (top-level user attr)", et.User)
|
||||
}
|
||||
if cmd := et.Wrap("uptime"); cmd != "uptime" {
|
||||
t.Errorf("host wrap must be identity, got %q", cmd)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user