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) } } func TestResolveExecTargetForCheckServiceRoutesViaHostingGuest(t *testing.T) { // A service has no address of its own; it must route through its hosting // LXC via the provides edge, host-hopping through the LXC's proxmox host. pool := newRemotePool(t) ctx := context.Background() hostID := uuid.New() guestID := uuid.New() svcID := 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:gitea','lxc','gitea','active','{"pve_id":"104","lan_ip":"192.168.8.121"}'::jsonb,1,now(),now())`, guestID) mustExec(t, pool, ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at) VALUES ($1,'service:gitea','service','gitea','active','{}'::jsonb,1,now(),now())`, svcID) // provides: lxc -> service; hosts: proxmox-host -> lxc mustExec(t, pool, ctx, `INSERT INTO relationships (source_id, target_id, type, valid_from, created_at) VALUES ($1,$2,'provides',now(),now())`, guestID, svcID) 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, svcID, "service", DefaultUser) if err != nil { t.Fatalf("ResolveExecTargetForCheck for service: %v", err) } // Reaches the proxmox host (host-hop), wrapped as pct exec into the guest. if et.Host != "192.168.8.77" { t.Errorf("Host = %q, want proxmox host 192.168.8.77 (via provides->hosts)", et.Host) } if out := et.Wrap("p"); !strings.Contains(out, "pct exec 104") { t.Errorf("service check must wrap as pct exec 104, got %q", out) } }