Files
osmedeus/internal/functions/ssh_functions.go
T
j3ssie baac7a016a feat: add worker management, hooks support, and db cleanup enhancements
- Add worker eval command for distributed function execution with Redis hooks registration
- Add worker set command to update worker fields (alias, public-ip, ssh-enabled, ssh-keys-path)
- Enhance worker status with JSON output, search filtering, and column selection (--columns, --exclude-columns, --search)
- Add --keep-setting flag to install base/validate commands to preserve osm-settings.yaml after base installation
- Fix binary installation in Nix: replace CopyInstalledBinaryToFolder with SymlinkInstalledBinaryToFolder
- Add --clean-ws flag to db clean command for removing workspace data
- Add HooksEnabled field to Run records when creating runs from CLI and API
- Add comprehensive test coverage for hook execution (pre/post hooks, execution order, failure handling)
- Add test coverage for worker commands (eval, set, status with JSON) and db clean operations
- Improve usage documentation for worker subcommands and db operations
2026-02-15 10:47:44 +07:00

410 lines
13 KiB
Go

package functions
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/dop251/goja"
"github.com/j3ssie/osmedeus/v5/internal/core"
"github.com/j3ssie/osmedeus/v5/internal/logger"
"github.com/j3ssie/osmedeus/v5/internal/runner"
"github.com/j3ssie/osmedeus/v5/internal/terminal"
"go.uber.org/zap"
)
// sshExpandPath expands ~ to the user's home directory
func sshExpandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}
// sshConfig holds parsed SSH connection parameters
type sshConfig struct {
User string
KeyFile string
Password string
Port int
}
// parseSSHConfig extracts optional SSH parameters from goja function call arguments
// starting at paramOffset. Parameters are: user, key_path, password, port
// Returns defaults: user="root", port=22
func parseSSHConfig(call goja.FunctionCall, paramOffset int) sshConfig {
cfg := sshConfig{
User: "root",
Port: 22,
}
if len(call.Arguments) > paramOffset {
v := call.Argument(paramOffset).String()
if v != "undefined" && v != "" {
cfg.User = v
}
}
if len(call.Arguments) > paramOffset+1 {
v := call.Argument(paramOffset + 1).String()
if v != "undefined" && v != "" {
cfg.KeyFile = v
}
}
if len(call.Arguments) > paramOffset+2 {
v := call.Argument(paramOffset + 2).String()
if v != "undefined" && v != "" {
cfg.Password = v
}
}
if len(call.Arguments) > paramOffset+3 {
v := call.Argument(paramOffset + 3)
if portVal, ok := v.Export().(int64); ok && portVal > 0 {
cfg.Port = int(portVal)
} else if portFloat, ok := v.Export().(float64); ok && portFloat > 0 {
cfg.Port = int(portFloat)
}
}
return cfg
}
// sshExec executes a command on a remote host via SSH using the connection pool
// Usage: ssh_exec(host, command, user?, key_path?, password?, port?) -> string
func (vf *vmFunc) sshExec(call goja.FunctionCall) goja.Value {
host := call.Argument(0).String()
command := call.Argument(1).String()
logger.Get().Debug("Calling "+terminal.HiGreen(FnSSHExec),
zap.String("host", host), zap.Int("commandLength", len(command)))
if host == "undefined" || host == "" {
logger.Get().Warn(FnSSHExec + ": host is required")
return vf.vm.ToValue("")
}
if command == "undefined" || command == "" {
logger.Get().Warn(FnSSHExec + ": command is required")
return vf.vm.ToValue("")
}
// Parse optional SSH config (user, key_path, password, port) starting at arg index 2
cfg := parseSSHConfig(call, 2)
// Build RunnerConfig for SSH pool
runnerConfig := &core.RunnerConfig{
Host: host,
Port: cfg.Port,
User: cfg.User,
KeyFile: sshExpandPath(cfg.KeyFile),
Password: cfg.Password,
}
// Use a 5-minute timeout context
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Get pooled SSH connection
pool := runner.GetSSHPool()
client, poolKey, err := pool.Get(ctx, runnerConfig)
if err != nil {
logger.Get().Warn(FnSSHExec+": SSH connection failed",
zap.String("host", host), zap.Error(err))
return vf.vm.ToValue("")
}
defer pool.Release(poolKey)
// Create session and run command
session, err := client.NewSession()
if err != nil {
logger.Get().Warn(FnSSHExec+": failed to create SSH session",
zap.String("host", host), zap.Error(err))
return vf.vm.ToValue("")
}
defer func() { _ = session.Close() }()
output, err := session.CombinedOutput(command)
if err != nil {
logger.Get().Warn(FnSSHExec+": command execution failed",
zap.String("host", host), zap.String("command", command), zap.Error(err))
// Return partial output even on error (non-zero exit code)
return vf.vm.ToValue(strings.TrimSpace(string(output)))
}
result := strings.TrimSpace(string(output))
logger.Get().Debug(terminal.HiGreen(FnSSHExec)+" result",
zap.String("host", host), zap.Int("outputLength", len(result)))
return vf.vm.ToValue(result)
}
// sshRsync copies a local file/directory to a remote host via rsync over SSH
// Usage: ssh_rsync(host, src, dest, user?, key_path?, password?, port?) -> bool
func (vf *vmFunc) sshRsync(call goja.FunctionCall) goja.Value {
host := call.Argument(0).String()
src := call.Argument(1).String()
dest := call.Argument(2).String()
logger.Get().Debug("Calling "+terminal.HiGreen(FnSSHRsync),
zap.String("host", host), zap.String("src", src), zap.String("dest", dest))
if host == "undefined" || host == "" {
logger.Get().Warn(FnSSHRsync + ": host is required")
return vf.vm.ToValue(false)
}
if src == "undefined" || src == "" {
logger.Get().Warn(FnSSHRsync + ": src is required")
return vf.vm.ToValue(false)
}
if dest == "undefined" || dest == "" {
logger.Get().Warn(FnSSHRsync + ": dest is required")
return vf.vm.ToValue(false)
}
// Parse optional SSH config (user, key_path, password, port) starting at arg index 3
cfg := parseSSHConfig(call, 3)
// Expand local source path
src = sshExpandPath(src)
// Build rsync destination: user@host:dest
rsyncDest := fmt.Sprintf("%s@%s:%s", cfg.User, host, dest)
// Use a 5-minute timeout context for rsync
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Build rsync command with SSH options
// Add ConnectTimeout to prevent SSH from hanging on unreachable hosts
args := []string{"-avz", "-e"}
keyPath := sshExpandPath(cfg.KeyFile)
if cfg.Password != "" && cfg.KeyFile == "" {
// Password-only auth: use sshpass wrapper
sshCmd := fmt.Sprintf("ssh -p %d -o StrictHostKeyChecking=no -o ConnectTimeout=30", cfg.Port)
args = append(args, sshCmd, src, rsyncDest)
// Wrap with sshpass
sshpassArgs := append([]string{"-p", cfg.Password, "rsync"}, args...)
cmd := exec.CommandContext(ctx, "sshpass", sshpassArgs...)
output, err := cmd.CombinedOutput()
if err != nil {
logger.Get().Warn(FnSSHRsync+": rsync with sshpass failed",
zap.String("host", host), zap.Error(err), zap.String("output", string(output)))
return vf.vm.ToValue(false)
}
} else {
// Key-based auth (or key + password)
if keyPath != "" {
args = append(args, fmt.Sprintf("ssh -i %s -p %d -o StrictHostKeyChecking=no -o ConnectTimeout=30", keyPath, cfg.Port))
} else {
args = append(args, fmt.Sprintf("ssh -p %d -o StrictHostKeyChecking=no -o ConnectTimeout=30", cfg.Port))
}
args = append(args, src, rsyncDest)
cmd := exec.CommandContext(ctx, "rsync", args...)
output, err := cmd.CombinedOutput()
if err != nil {
logger.Get().Warn(FnSSHRsync+": rsync failed",
zap.String("host", host), zap.Error(err), zap.String("output", string(output)))
return vf.vm.ToValue(false)
}
}
logger.Get().Debug(terminal.HiGreen(FnSSHRsync)+" completed",
zap.String("host", host), zap.String("src", src), zap.String("dest", dest))
return vf.vm.ToValue(true)
}
// resolveWorkerSSHParams merges WorkerSSHInfo (from Redis lookup) with an explicit IP override.
// Returns the resolved host, user, keyPath, and port for rsync.
func resolveWorkerSSHParams(info *WorkerSSHInfo, ip string) (host, user, keyPath string, port int) {
user = "root"
port = 22
if info != nil {
host = info.Host
user = info.User
keyPath = info.KeyPath
if info.Port > 0 {
port = info.Port
}
}
// Explicit IP overrides resolved host
if ip != "undefined" && ip != "" {
host = ip
}
return
}
// executeRsync runs rsync with SSH options. src and dest should already include
// user@host: prefix where appropriate.
func executeRsync(src, dest, keyPath string, port int) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
args := []string{"-avz", "-e"}
keyPath = sshExpandPath(keyPath)
if keyPath != "" {
args = append(args, fmt.Sprintf("ssh -i %s -p %d -o StrictHostKeyChecking=no -o ConnectTimeout=30", keyPath, port))
} else {
args = append(args, fmt.Sprintf("ssh -p %d -o StrictHostKeyChecking=no -o ConnectTimeout=30", port))
}
args = append(args, src, dest)
cmd := exec.CommandContext(ctx, "rsync", args...)
output, err := cmd.CombinedOutput()
if err != nil {
logger.Get().Warn("rsync failed",
zap.String("src", src), zap.String("dest", dest),
zap.Error(err), zap.String("output", string(output)))
return false
}
return true
}
// syncFromMaster requests the master to rsync a file/folder to this worker.
// In distributed mode, sends a "sync_to_worker" execute request to the master.
// In standalone mode, falls back to local cp -r.
// Usage: sync_from_master(src, dest) -> bool
func (vf *vmFunc) syncFromMaster(call goja.FunctionCall) goja.Value {
src := call.Argument(0).String()
dest := call.Argument(1).String()
logger.Get().Debug("Calling "+terminal.HiGreen(FnSyncFromMaster),
zap.String("src", src), zap.String("dest", dest))
if src == "undefined" || src == "" {
logger.Get().Warn(FnSyncFromMaster + ": src is required")
return vf.vm.ToValue(false)
}
if dest == "undefined" || dest == "" {
logger.Get().Warn(FnSyncFromMaster + ": dest is required")
return vf.vm.ToValue(false)
}
// Try distributed: send "sync_to_worker" request to master.
// Master will look up this worker's SSH info and rsync src to worker:dest.
// Data=src, Target=dest, TargetRole="master"
if trySendExecuteRequest("sync_to_worker", src, "", dest, "", "master", "") {
logger.Get().Debug(terminal.HiGreen(FnSyncFromMaster)+" sent to master via Redis",
zap.String("src", src), zap.String("dest", dest))
return vf.vm.ToValue(true)
}
// Fallback: local cp -r (standalone mode)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "cp", "-r", src, dest)
output, err := cmd.CombinedOutput()
if err != nil {
logger.Get().Warn(FnSyncFromMaster+": local cp fallback failed",
zap.String("src", src), zap.String("dest", dest),
zap.Error(err), zap.String("output", string(output)))
return vf.vm.ToValue(false)
}
logger.Get().Debug(terminal.HiGreen(FnSyncFromMaster)+" completed via local cp",
zap.String("src", src), zap.String("dest", dest))
return vf.vm.ToValue(true)
}
// syncFromWorker pulls a file/folder from a specific worker via rsync over SSH.
// Usage: sync_from_worker(identifier, ip, src, dest) -> bool
func (vf *vmFunc) syncFromWorker(call goja.FunctionCall) goja.Value {
identifier := call.Argument(0).String()
ip := call.Argument(1).String()
src := call.Argument(2).String()
dest := call.Argument(3).String()
logger.Get().Debug("Calling "+terminal.HiGreen(FnSyncFromWorker),
zap.String("identifier", identifier), zap.String("ip", ip),
zap.String("src", src), zap.String("dest", dest))
if identifier == "undefined" || identifier == "" {
logger.Get().Warn(FnSyncFromWorker + ": identifier is required")
return vf.vm.ToValue(false)
}
if src == "undefined" || src == "" {
logger.Get().Warn(FnSyncFromWorker + ": src is required")
return vf.vm.ToValue(false)
}
if dest == "undefined" || dest == "" {
logger.Get().Warn(FnSyncFromWorker + ": dest is required")
return vf.vm.ToValue(false)
}
// Resolve worker SSH info from Redis
workerSSH := tryResolveWorkerSSH(identifier)
host, user, keyPath, port := resolveWorkerSSHParams(workerSSH, ip)
if host == "" {
logger.Get().Warn(FnSyncFromWorker+": no host resolved",
zap.String("identifier", identifier), zap.String("ip", ip))
return vf.vm.ToValue(false)
}
// rsync pull: user@host:src -> dest
rsyncSrc := fmt.Sprintf("%s@%s:%s", user, host, src)
ok := executeRsync(rsyncSrc, dest, keyPath, port)
if ok {
logger.Get().Debug(terminal.HiGreen(FnSyncFromWorker)+" completed",
zap.String("host", host), zap.String("src", src), zap.String("dest", dest))
}
return vf.vm.ToValue(ok)
}
// rsyncToWorker pushes a file/folder to a specific worker via rsync over SSH.
// Usage: rsync_to_worker(identifier, ip, src, dest) -> bool
func (vf *vmFunc) rsyncToWorker(call goja.FunctionCall) goja.Value {
identifier := call.Argument(0).String()
ip := call.Argument(1).String()
src := call.Argument(2).String()
dest := call.Argument(3).String()
logger.Get().Debug("Calling "+terminal.HiGreen(FnRsyncToWorker),
zap.String("identifier", identifier), zap.String("ip", ip),
zap.String("src", src), zap.String("dest", dest))
if identifier == "undefined" || identifier == "" {
logger.Get().Warn(FnRsyncToWorker + ": identifier is required")
return vf.vm.ToValue(false)
}
if src == "undefined" || src == "" {
logger.Get().Warn(FnRsyncToWorker + ": src is required")
return vf.vm.ToValue(false)
}
if dest == "undefined" || dest == "" {
logger.Get().Warn(FnRsyncToWorker + ": dest is required")
return vf.vm.ToValue(false)
}
// Resolve worker SSH info from Redis
workerSSH := tryResolveWorkerSSH(identifier)
host, user, keyPath, port := resolveWorkerSSHParams(workerSSH, ip)
if host == "" {
logger.Get().Warn(FnRsyncToWorker+": no host resolved",
zap.String("identifier", identifier), zap.String("ip", ip))
return vf.vm.ToValue(false)
}
// Expand local source path
src = sshExpandPath(src)
// rsync push: src -> user@host:dest
rsyncDest := fmt.Sprintf("%s@%s:%s", user, host, dest)
ok := executeRsync(src, rsyncDest, keyPath, port)
if ok {
logger.Get().Debug(terminal.HiGreen(FnRsyncToWorker)+" completed",
zap.String("host", host), zap.String("src", src), zap.String("dest", dest))
}
return vf.vm.ToValue(ok)
}