chore: publish pxmon v0.2.0
This commit is contained in:
@@ -0,0 +1,554 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "clusters.enc"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore error: %v", err)
|
||||
}
|
||||
|
||||
return NewService(store)
|
||||
}
|
||||
|
||||
func TestConnectListUseDisconnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
c1, _, err := svc.Connect(ctx, ConnectOptions{
|
||||
Name: "eu-1",
|
||||
Host: "10.0.0.10",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: AuthMethodPassword,
|
||||
Password: "pass1",
|
||||
SkipCheck: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("connect c1 error: %v", err)
|
||||
}
|
||||
|
||||
c2, _, err := svc.Connect(ctx, ConnectOptions{
|
||||
Name: "us-1",
|
||||
Host: "10.0.0.11",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: AuthMethodPassword,
|
||||
Password: "pass2",
|
||||
SkipCheck: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("connect c2 error: %v", err)
|
||||
}
|
||||
|
||||
clusters, activeID, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
if len(clusters) != 2 {
|
||||
t.Fatalf("expected 2 clusters, got %d", len(clusters))
|
||||
}
|
||||
if activeID != c2.ID {
|
||||
t.Fatalf("expected active %s, got %s", c2.ID, activeID)
|
||||
}
|
||||
|
||||
_, err = svc.Use(c1.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("use error: %v", err)
|
||||
}
|
||||
|
||||
current, err := svc.Current()
|
||||
if err != nil {
|
||||
t.Fatalf("current error: %v", err)
|
||||
}
|
||||
if current.ID != c1.ID {
|
||||
t.Fatalf("expected current %s, got %s", c1.ID, current.ID)
|
||||
}
|
||||
|
||||
_, err = svc.Disconnect(c1.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("disconnect error: %v", err)
|
||||
}
|
||||
|
||||
current, err = svc.Current()
|
||||
if err != nil {
|
||||
t.Fatalf("current after disconnect error: %v", err)
|
||||
}
|
||||
if current.ID != c2.ID {
|
||||
t.Fatalf("expected fallback current %s, got %s", c2.ID, current.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectDuplicateNameRequiresForce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, err := svc.Connect(ctx, ConnectOptions{
|
||||
Name: "prod",
|
||||
Host: "10.0.0.10",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: AuthMethodPassword,
|
||||
Password: "pass1",
|
||||
SkipCheck: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("initial connect error: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = svc.Connect(ctx, ConnectOptions{
|
||||
Name: "prod",
|
||||
Host: "10.0.0.20",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: AuthMethodPassword,
|
||||
Password: "pass2",
|
||||
SkipCheck: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
|
||||
c, _, err := svc.Connect(ctx, ConnectOptions{
|
||||
Name: "prod",
|
||||
Host: "10.0.0.20",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: AuthMethodPassword,
|
||||
Password: "pass2",
|
||||
SkipCheck: true,
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("force connect error: %v", err)
|
||||
}
|
||||
if c.Host != "10.0.0.20" {
|
||||
t.Fatalf("expected overwritten host, got %s", c.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportImportRestoresKeyFilesAndNewFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srcDir := t.TempDir()
|
||||
keyPath := filepath.Join(srcDir, "id_ed25519")
|
||||
passPath := filepath.Join(srcDir, "pass.pxmonpassphrase")
|
||||
sftpKeyPath := filepath.Join(srcDir, "sftp_key")
|
||||
if err := os.WriteFile(keyPath, []byte("PRIVATE KEY\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(passPath, []byte("secret-pass\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sftpKeyPath, []byte("SFTP PRIVATE KEY\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src := newTestService(t)
|
||||
reg := newRegistry()
|
||||
reg.ActiveClusterID = "clu_1"
|
||||
reg.Backups.Targets = []BackupTarget{{
|
||||
ID: "bt_1",
|
||||
Name: "sftp",
|
||||
Type: "sftp",
|
||||
Enabled: true,
|
||||
SFTPKeyPath: sftpKeyPath,
|
||||
}}
|
||||
reg.Clusters = []Cluster{{
|
||||
ID: "clu_1",
|
||||
Name: "node",
|
||||
Host: "192.0.2.10",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
Transport: TransportIPFabric,
|
||||
AuthMethod: AuthMethodKey,
|
||||
KeyPath: keyPath,
|
||||
KeyPassphraseFile: passPath,
|
||||
RepoTunnel: RepoTunnelState{
|
||||
Enabled: true,
|
||||
Proxy: "http://203.0.113.10:3128",
|
||||
Source: "dnf",
|
||||
},
|
||||
Alerts: defaultAlertPolicy(),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}}
|
||||
if err := src.store.Save(reg); err != nil {
|
||||
t.Fatalf("save source registry: %v", err)
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(t.TempDir(), "pxmon-export.enc")
|
||||
if err := src.Export(bundlePath, "strong-test-passphrase"); err != nil {
|
||||
t.Fatalf("export error: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "PRIVATE KEY") || strings.Contains(string(raw), "secret-pass") {
|
||||
t.Fatal("export bundle leaked key material in plaintext")
|
||||
}
|
||||
|
||||
dst := newTestService(t)
|
||||
if _, err := dst.Import(bundlePath, "strong-test-passphrase", ImportModeReplace); err != nil {
|
||||
t.Fatalf("import error: %v", err)
|
||||
}
|
||||
gotReg, err := dst.store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load imported registry: %v", err)
|
||||
}
|
||||
if len(gotReg.Clusters) != 1 {
|
||||
t.Fatalf("expected 1 cluster, got %d", len(gotReg.Clusters))
|
||||
}
|
||||
got := gotReg.Clusters[0]
|
||||
if !got.RepoTunnel.Enabled || got.RepoTunnel.Proxy != "http://203.0.113.10:3128" {
|
||||
t.Fatalf("repo tunnel was not preserved: %+v", got.RepoTunnel)
|
||||
}
|
||||
if got.KeyPath == keyPath || got.KeyPassphraseFile == passPath {
|
||||
t.Fatalf("expected key paths to be restored under destination config dir, got key=%q pass=%q", got.KeyPath, got.KeyPassphraseFile)
|
||||
}
|
||||
keyData, err := os.ReadFile(got.KeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored key: %v", err)
|
||||
}
|
||||
if string(keyData) != "PRIVATE KEY\n" {
|
||||
t.Fatalf("unexpected restored key content: %q", string(keyData))
|
||||
}
|
||||
passData, err := os.ReadFile(got.KeyPassphraseFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored passphrase file: %v", err)
|
||||
}
|
||||
if string(passData) != "secret-pass\n" {
|
||||
t.Fatalf("unexpected restored passphrase content: %q", string(passData))
|
||||
}
|
||||
if len(gotReg.Backups.Targets) != 1 || gotReg.Backups.Targets[0].SFTPKeyPath == sftpKeyPath {
|
||||
t.Fatalf("expected restored SFTP key path, got %+v", gotReg.Backups.Targets)
|
||||
}
|
||||
|
||||
mergeDst := newTestService(t)
|
||||
if _, err := mergeDst.Import(bundlePath, "strong-test-passphrase", ImportModeMerge); err != nil {
|
||||
t.Fatalf("merge import error: %v", err)
|
||||
}
|
||||
mergeReg, err := mergeDst.store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load merge registry: %v", err)
|
||||
}
|
||||
if len(mergeReg.Backups.Targets) != 1 || strings.TrimSpace(mergeReg.Backups.Targets[0].SFTPKeyPath) == "" {
|
||||
t.Fatalf("expected backup target to be merged, got %+v", mergeReg.Backups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectRequiresAuthData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, err := svc.Connect(ctx, ConnectOptions{
|
||||
Name: "bad",
|
||||
Host: "10.0.0.10",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: AuthMethodPassword,
|
||||
SkipCheck: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty password auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertPolicySetAndGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, err := svc.Connect(ctx, ConnectOptions{
|
||||
Name: "node-1",
|
||||
Host: "10.0.0.10",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: AuthMethodPassword,
|
||||
Password: "pass",
|
||||
SkipCheck: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("connect error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := svc.SetAlertPolicy("node-1", AlertPolicy{
|
||||
CPUWarnPercent: 70,
|
||||
RAMWarnPercent: 75,
|
||||
SwapWarnPercent: 60,
|
||||
DiskWarnPercent: 80,
|
||||
NetWarnMbps: 120,
|
||||
NetSustainEnabled: true,
|
||||
NetSustainIface: "eth0",
|
||||
NetSustainInclude: []string{"net0"},
|
||||
NetSustainExclude: []string{"backup"},
|
||||
NetSustainMbps: 500,
|
||||
NetSustainMinutes: 60,
|
||||
NetSustainCooldownMins: 15,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("set alert policy error: %v", err)
|
||||
}
|
||||
|
||||
if updated.Alerts.NetWarnMbps != 120 {
|
||||
t.Fatalf("unexpected net threshold: %.2f", updated.Alerts.NetWarnMbps)
|
||||
}
|
||||
|
||||
got, err := svc.GetAlertPolicy("node-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get alert policy error: %v", err)
|
||||
}
|
||||
if got.RAMWarnPercent != 75 || got.DiskWarnPercent != 80 {
|
||||
t.Fatalf("unexpected thresholds: %+v", got)
|
||||
}
|
||||
if !got.NetSustainEnabled || got.NetSustainIface != "eth0" || got.NetSustainMbps != 500 {
|
||||
t.Fatalf("unexpected sustained net policy: %+v", got)
|
||||
}
|
||||
if len(got.NetSustainInclude) != 1 || got.NetSustainInclude[0] != "net0" {
|
||||
t.Fatalf("unexpected sustained net include filter: %+v", got.NetSustainInclude)
|
||||
}
|
||||
if len(got.NetSustainExclude) != 1 || got.NetSustainExclude[0] != "backup" {
|
||||
t.Fatalf("unexpected sustained net exclude filter: %+v", got.NetSustainExclude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPluginToolSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info := SoftwareInfo{
|
||||
Bird: true,
|
||||
FRR: true,
|
||||
KVM: true,
|
||||
LXC: true,
|
||||
LXD: false,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
tool string
|
||||
ok bool
|
||||
}{
|
||||
{tool: "bird", ok: true},
|
||||
{tool: "frr", ok: true},
|
||||
{tool: "kvm", ok: true},
|
||||
{tool: "lxc", ok: true},
|
||||
{tool: "lxd", ok: true}, // lxd aliases to lxc command templates
|
||||
{tool: "unknown", ok: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.tool, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := isPluginToolSupported(info, tt.tool); got != tt.ok {
|
||||
t.Fatalf("tool=%s expected %v got %v", tt.tool, tt.ok, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLXDTopUsesTemplateNotRawLxcTop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script, err := pluginScript("lxd", "top", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("pluginScript error: %v", err)
|
||||
}
|
||||
if strings.Contains(script, "lxc top") {
|
||||
t.Fatalf("expected custom template script, got raw lxc top: %q", script)
|
||||
}
|
||||
if !strings.Contains(script, "lxc info") {
|
||||
t.Fatalf("expected lxc info usage in top template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPluginActionReportsMissingSupport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cluster, _, err := svc.Connect(ctx, ConnectOptions{
|
||||
Name: "eu-1",
|
||||
Host: "127.0.0.1",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
AuthMethod: AuthMethodPassword,
|
||||
Password: "pass",
|
||||
SkipCheck: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("connect error: %v", err)
|
||||
}
|
||||
|
||||
reg, err := svc.store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load registry error: %v", err)
|
||||
}
|
||||
for i := range reg.Clusters {
|
||||
if reg.Clusters[i].ID == cluster.ID {
|
||||
reg.Clusters[i].Software = SoftwareInfo{
|
||||
DetectedAt: time.Now().UTC(),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := svc.store.Save(reg); err != nil {
|
||||
t.Fatalf("save registry error: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.RunPluginAction(ctx, cluster.Name, "lxd", "top", nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected unsupported software error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "support for lxd was not detected") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVMTopScriptIncludesReadableMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script, err := pluginScript("kvm", "top", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("pluginScript error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"VCPU",
|
||||
"RAM_MAX",
|
||||
"DISK_CAP",
|
||||
"DISK_ALLOC",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("expected %q in kvm top script", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVMNetTopScriptIncludesRateAndP95(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script, err := pluginScript("kvm", "net-top", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("pluginScript error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"NET_Mbps",
|
||||
"RX_Mbps",
|
||||
"TX_Mbps",
|
||||
"P95_Mbps",
|
||||
"RX_TOTAL",
|
||||
"TX_TOTAL",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("expected %q in kvm net-top script", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVMScriptsAreShellParseable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
tool string
|
||||
action string
|
||||
}{
|
||||
{tool: "kvm", action: "top"},
|
||||
{tool: "kvm", action: "net-top"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.tool+"-"+tc.action, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
script, err := pluginScript(tc.tool, tc.action, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("pluginScript error: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", "-n", "-c", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("shell parse failed: %v\n%s\nSCRIPT:\n%s", err, string(out), script)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramConfigSetGetDisable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newTestService(t)
|
||||
cfg, err := svc.SetTelegram(Telegram{
|
||||
Enabled: true,
|
||||
Token: "123:ABC",
|
||||
AllowedUserIDs: []int64{2002, 1001, 2002},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("set telegram config: %v", err)
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("expected enabled telegram config")
|
||||
}
|
||||
if len(cfg.AllowedUserIDs) != 2 {
|
||||
t.Fatalf("expected deduped ids, got %+v", cfg.AllowedUserIDs)
|
||||
}
|
||||
|
||||
got, err := svc.GetTelegram()
|
||||
if err != nil {
|
||||
t.Fatalf("get telegram config: %v", err)
|
||||
}
|
||||
if got.Token != "123:ABC" {
|
||||
t.Fatalf("unexpected token: %q", got.Token)
|
||||
}
|
||||
if len(got.AllowedUserIDs) != 2 || got.AllowedUserIDs[0] != 1001 || got.AllowedUserIDs[1] != 2002 {
|
||||
t.Fatalf("unexpected allowed ids: %+v", got.AllowedUserIDs)
|
||||
}
|
||||
|
||||
disabled, err := svc.DisableTelegram()
|
||||
if err != nil {
|
||||
t.Fatalf("disable telegram config: %v", err)
|
||||
}
|
||||
if disabled.Enabled {
|
||||
t.Fatal("expected disabled telegram config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramConfigValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := newTestService(t)
|
||||
_, err := svc.SetTelegram(Telegram{
|
||||
Enabled: true,
|
||||
AllowedUserIDs: []int64{123},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected token validation error")
|
||||
}
|
||||
|
||||
_, err = svc.SetTelegram(Telegram{
|
||||
Enabled: true,
|
||||
Token: "123:ABC",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected allowed ids validation error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user