mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-24 16:42:28 +02:00
- Add QueuePoller for dual-source (DB + Redis) task polling with concurrent execution - Add worker queue subcommands (list, new, run) for managing queued tasks - Add uninstall command to safely remove Osmedeus installation - Add input validation to prevent command injection via dangerous shell characters - Add queue run support via --queue and --queue-run flags in osmedeus run - Add queue polling to server with --no-queue-polling flag to disable it - Support queuing tasks from both CLI and API with deduplication across sources
742 lines
21 KiB
Go
742 lines
21 KiB
Go
package distributed
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/j3ssie/osmedeus/v5/internal/config"
|
|
"github.com/j3ssie/osmedeus/v5/internal/core"
|
|
"github.com/j3ssie/osmedeus/v5/internal/database"
|
|
"github.com/j3ssie/osmedeus/v5/internal/executor"
|
|
"github.com/j3ssie/osmedeus/v5/internal/functions"
|
|
"github.com/j3ssie/osmedeus/v5/internal/heuristics"
|
|
"github.com/j3ssie/osmedeus/v5/internal/json"
|
|
"github.com/j3ssie/osmedeus/v5/internal/parser"
|
|
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
|
)
|
|
|
|
// WorkerOptions holds optional configuration for creating a new Worker.
|
|
type WorkerOptions struct {
|
|
GetPublicIP bool
|
|
Alias string
|
|
SSHEnabled bool
|
|
SSHKeysPath string
|
|
}
|
|
|
|
// Worker represents a worker node that processes tasks
|
|
type Worker struct {
|
|
ID string
|
|
Hostname string
|
|
client *Client
|
|
config *config.Config
|
|
executor *executor.Executor
|
|
loader *parser.Loader
|
|
printer *terminal.Printer
|
|
|
|
// Cleanup function for distributed hooks
|
|
unregisterHooks func()
|
|
|
|
// Metadata
|
|
ipAddress string
|
|
publicIP string
|
|
sshEnabled bool
|
|
sshKeysPath string
|
|
alias string
|
|
|
|
// Stats
|
|
tasksComplete int
|
|
tasksFailed int
|
|
}
|
|
|
|
// NewWorker creates a new worker node
|
|
func NewWorker(cfg *config.Config, opts *WorkerOptions) (*Worker, error) {
|
|
if opts == nil {
|
|
opts = &WorkerOptions{}
|
|
}
|
|
|
|
client, err := NewClientFromConfig(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create redis client: %w", err)
|
|
}
|
|
|
|
hostname, _ := os.Hostname()
|
|
workerID := fmt.Sprintf("wosm-%s", uuid.NewString()[:8])
|
|
|
|
exec := executor.NewExecutor()
|
|
loader := parser.NewLoader(cfg.WorkflowsPath)
|
|
exec.SetLoader(loader)
|
|
|
|
p := terminal.NewPrinter()
|
|
|
|
w := &Worker{
|
|
ID: workerID,
|
|
Hostname: hostname,
|
|
client: client,
|
|
config: cfg,
|
|
executor: exec,
|
|
loader: loader,
|
|
printer: p,
|
|
ipAddress: getOutboundIP(),
|
|
sshEnabled: opts.SSHEnabled,
|
|
sshKeysPath: opts.SSHKeysPath,
|
|
alias: opts.Alias,
|
|
}
|
|
|
|
if opts.GetPublicIP {
|
|
w.publicIP = fetchPublicIP()
|
|
if w.publicIP != "" {
|
|
p.Info("Detected public IP: %s", terminal.Cyan(w.publicIP))
|
|
} else {
|
|
p.Warning("Could not detect public IP")
|
|
}
|
|
}
|
|
|
|
// Default alias: wosm-<public-ip> or wosm-<ip-address>
|
|
if w.alias == "" {
|
|
if w.publicIP != "" {
|
|
w.alias = fmt.Sprintf("wosm-%s", w.publicIP)
|
|
} else if w.ipAddress != "" {
|
|
w.alias = fmt.Sprintf("wosm-%s", w.ipAddress)
|
|
}
|
|
}
|
|
|
|
return w, nil
|
|
}
|
|
|
|
// getOutboundIP returns the preferred outbound IP address of the machine.
|
|
// It uses a UDP dial to 8.8.8.8:80 (no actual packet is sent) to determine the source address.
|
|
func getOutboundIP() string {
|
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
addr := conn.LocalAddr().(*net.UDPAddr)
|
|
return addr.IP.String()
|
|
}
|
|
|
|
// fetchPublicIP fetches the public IP from ipinfo.io.
|
|
func fetchPublicIP() string {
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
req, err := http.NewRequest("GET", "https://ipinfo.io/ip", nil)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
req.Header.Set("User-Agent", core.DefaultUA)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(body))
|
|
}
|
|
|
|
// Run starts the worker loop
|
|
func (w *Worker) Run(ctx context.Context) error {
|
|
// Test connection
|
|
if err := w.client.Ping(ctx); err != nil {
|
|
return fmt.Errorf("failed to connect to redis: %w", err)
|
|
}
|
|
|
|
// Register worker
|
|
if err := w.register(ctx); err != nil {
|
|
return fmt.Errorf("failed to register worker: %w", err)
|
|
}
|
|
|
|
// Set worker mode in config
|
|
config.SetWorkerMode(true, w.ID)
|
|
|
|
// Register distributed hooks for database writes
|
|
w.registerDistributedHooks()
|
|
defer w.unregisterDistributedHooks()
|
|
|
|
w.printer.Success("Worker %s joined successfully", terminal.Cyan(w.ID))
|
|
w.printer.Info("Waiting for tasks...")
|
|
|
|
// Start heartbeat goroutine
|
|
heartbeatCtx, cancelHeartbeat := context.WithCancel(ctx)
|
|
defer cancelHeartbeat()
|
|
go w.heartbeatLoop(heartbeatCtx)
|
|
|
|
// Start execute listener goroutine for per-worker execute requests
|
|
executeCtx, cancelExecute := context.WithCancel(ctx)
|
|
defer cancelExecute()
|
|
go w.executeListenerLoop(executeCtx)
|
|
|
|
// Main task loop
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
w.printer.Info("Worker %s shutting down...", terminal.Cyan(w.ID))
|
|
w.cleanup(context.Background())
|
|
return nil
|
|
default:
|
|
if err := w.processNextTask(ctx); err != nil {
|
|
// Suppress context-canceled errors during shutdown
|
|
if ctx.Err() != nil {
|
|
continue
|
|
}
|
|
w.printer.Warning("Error processing task: %s", err)
|
|
time.Sleep(time.Second) // Brief pause before retrying
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// register registers the worker with the master
|
|
func (w *Worker) register(ctx context.Context) error {
|
|
info := &WorkerInfo{
|
|
ID: w.ID,
|
|
Hostname: w.Hostname,
|
|
Status: "idle",
|
|
JoinedAt: time.Now(),
|
|
LastHeartbeat: time.Now(),
|
|
IPAddress: w.ipAddress,
|
|
PublicIP: w.publicIP,
|
|
SSHEnabled: w.sshEnabled,
|
|
SSHKeysPath: w.sshKeysPath,
|
|
Alias: w.alias,
|
|
}
|
|
|
|
if err := w.client.RegisterWorker(ctx, info); err != nil {
|
|
return err
|
|
}
|
|
|
|
return w.client.UpdateWorkerHeartbeat(ctx, w.ID)
|
|
}
|
|
|
|
// heartbeatLoop sends periodic heartbeats
|
|
func (w *Worker) heartbeatLoop(ctx context.Context) {
|
|
ticker := time.NewTicker(HeartbeatInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := w.client.UpdateWorkerHeartbeat(ctx, w.ID); err != nil {
|
|
w.printer.Warning("Failed to send heartbeat: %s", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// processNextTask waits for and processes the next task
|
|
func (w *Worker) processNextTask(ctx context.Context) error {
|
|
// Block waiting for a task
|
|
task, err := w.client.PopTask(ctx, TaskPollTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if task == nil {
|
|
return nil // Timeout, no task available
|
|
}
|
|
|
|
w.printer.Info("Received task %s: %s -> %s",
|
|
terminal.Cyan(task.ID), terminal.Yellow(task.WorkflowName), terminal.Green(task.Target))
|
|
|
|
// Mark task as running
|
|
task.MarkRunning(w.ID)
|
|
if err := w.client.SetTaskRunning(ctx, task); err != nil {
|
|
w.printer.Warning("Failed to mark task running: %s", err)
|
|
}
|
|
|
|
// Update worker status
|
|
w.updateStatus(ctx, "busy", task.ID)
|
|
|
|
// Execute the task
|
|
result := w.executeTask(ctx, task)
|
|
|
|
// Report result
|
|
if err := w.client.SetTaskResult(ctx, result); err != nil {
|
|
w.printer.Warning("Failed to report task result: %s", err)
|
|
}
|
|
|
|
// Remove from running
|
|
if err := w.client.RemoveTaskRunning(ctx, task.ID); err != nil {
|
|
w.printer.Warning("Failed to remove task from running: %s", err)
|
|
}
|
|
|
|
// Update stats and status
|
|
if result.Status == TaskStatusCompleted {
|
|
w.tasksComplete++
|
|
w.printer.Success("Task %s completed", terminal.Cyan(task.ID))
|
|
} else {
|
|
w.tasksFailed++
|
|
w.printer.Error("Task %s failed: %s", terminal.Cyan(task.ID), result.Error)
|
|
}
|
|
w.updateStatus(ctx, "idle", "")
|
|
|
|
return nil
|
|
}
|
|
|
|
// executeTask executes a workflow task
|
|
func (w *Worker) executeTask(ctx context.Context, task *Task) *TaskResult {
|
|
result := &TaskResult{
|
|
TaskID: task.ID,
|
|
CompletedAt: time.Now(),
|
|
}
|
|
|
|
// If task uses a file input, ensure the file exists locally
|
|
if task.InputIsFile && task.InputFilePath != "" {
|
|
if _, err := os.Stat(task.InputFilePath); os.IsNotExist(err) {
|
|
w.printer.Warning("Input file %s not found locally, attempting rsync from master", task.InputFilePath)
|
|
if syncErr := w.syncFileFromMaster(ctx, task.InputFilePath); syncErr != nil {
|
|
result.Status = TaskStatusFailed
|
|
result.Error = fmt.Sprintf("input file not available: %v", syncErr)
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load workflow
|
|
workflow, err := w.loader.LoadWorkflow(task.WorkflowName)
|
|
if err != nil {
|
|
result.Status = TaskStatusFailed
|
|
result.Error = fmt.Sprintf("failed to load workflow: %v", err)
|
|
return result
|
|
}
|
|
|
|
// Convert params to string map
|
|
params := make(map[string]string)
|
|
params["target"] = task.Target
|
|
for k, v := range task.Params {
|
|
if s, ok := v.(string); ok {
|
|
params[k] = s
|
|
}
|
|
}
|
|
|
|
// Create run record for distributed tracking
|
|
now := time.Now()
|
|
runUUID := uuid.New().String()
|
|
paramsInterface := make(map[string]interface{})
|
|
for k, v := range params {
|
|
paramsInterface[k] = v
|
|
}
|
|
totalSteps := countWorkflowSteps(workflow, w.loader)
|
|
|
|
run := &database.Run{
|
|
RunUUID: runUUID,
|
|
WorkflowName: workflow.Name,
|
|
WorkflowKind: string(workflow.Kind),
|
|
Target: task.Target,
|
|
Params: paramsInterface,
|
|
Status: "running",
|
|
TriggerType: "distributed",
|
|
StartedAt: &now,
|
|
TotalSteps: totalSteps,
|
|
Workspace: computeWorkspace(task.Target),
|
|
RunPriority: "high",
|
|
RunMode: "distributed",
|
|
HooksEnabled: workflow.HookCount() > 0,
|
|
}
|
|
// Goes through distributed hooks → Redis → master DB
|
|
_ = database.CreateRun(ctx, run)
|
|
|
|
// Wire up executor for run tracking
|
|
w.executor.SetDBRunUUID(runUUID)
|
|
|
|
// Execute based on workflow kind
|
|
var wfResult *core.WorkflowResult
|
|
if workflow.IsFlow() {
|
|
wfResult, err = w.executor.ExecuteFlow(ctx, workflow, params, w.config)
|
|
} else {
|
|
wfResult, err = w.executor.ExecuteModule(ctx, workflow, params, w.config)
|
|
}
|
|
|
|
// Determine final status and error message
|
|
var finalStatus string
|
|
var errorMsg string
|
|
if err != nil {
|
|
finalStatus = "failed"
|
|
errorMsg = err.Error()
|
|
result.Status = TaskStatusFailed
|
|
result.Error = err.Error()
|
|
} else if wfResult.Status == core.RunStatusFailed {
|
|
finalStatus = "failed"
|
|
result.Status = TaskStatusFailed
|
|
if wfResult.Error != nil {
|
|
errorMsg = wfResult.Error.Error()
|
|
result.Error = errorMsg
|
|
} else {
|
|
errorMsg = "workflow execution failed"
|
|
result.Error = errorMsg
|
|
}
|
|
} else {
|
|
finalStatus = "completed"
|
|
result.Status = TaskStatusCompleted
|
|
result.Exports = wfResult.Exports
|
|
}
|
|
|
|
// Send final status update to master via Redis hooks
|
|
completedAt := time.Now()
|
|
run.Status = finalStatus
|
|
run.ErrorMessage = errorMsg
|
|
run.CompletedAt = &completedAt
|
|
run.UpdatedAt = completedAt
|
|
if finalStatus == "completed" {
|
|
run.CompletedSteps = totalSteps
|
|
}
|
|
_ = database.CreateRun(ctx, run) // upsert — master matches by run_uuid
|
|
|
|
result.CompletedAt = completedAt
|
|
return result
|
|
}
|
|
|
|
// updateStatus updates the worker's status in Redis
|
|
func (w *Worker) updateStatus(ctx context.Context, status string, taskID string) {
|
|
info := &WorkerInfo{
|
|
ID: w.ID,
|
|
Hostname: w.Hostname,
|
|
Status: status,
|
|
CurrentTaskID: taskID,
|
|
JoinedAt: time.Now(), // This will be overwritten, but we need a value
|
|
LastHeartbeat: time.Now(),
|
|
TasksComplete: w.tasksComplete,
|
|
TasksFailed: w.tasksFailed,
|
|
IPAddress: w.ipAddress,
|
|
PublicIP: w.publicIP,
|
|
SSHEnabled: w.sshEnabled,
|
|
SSHKeysPath: w.sshKeysPath,
|
|
Alias: w.alias,
|
|
}
|
|
if err := w.client.RegisterWorker(ctx, info); err != nil {
|
|
w.printer.Warning("Failed to update worker status: %s", err)
|
|
}
|
|
}
|
|
|
|
// cleanup removes the worker from the registry
|
|
func (w *Worker) cleanup(ctx context.Context) {
|
|
w.printer.Info("Cleaning up worker %s...", terminal.Cyan(w.ID))
|
|
if err := w.client.RemoveWorker(ctx, w.ID); err != nil {
|
|
w.printer.Warning("Failed to remove worker: %s", err)
|
|
}
|
|
w.client.Close()
|
|
}
|
|
|
|
// GetID returns the worker ID
|
|
func (w *Worker) GetID() string {
|
|
return w.ID
|
|
}
|
|
|
|
// GetClient returns the Redis client
|
|
func (w *Worker) GetClient() *Client {
|
|
return w.client
|
|
}
|
|
|
|
// syncFileFromMaster attempts to sync a file from the master node via the data queue.
|
|
// It sends a sync request and waits briefly, but file availability is best-effort.
|
|
func (w *Worker) syncFileFromMaster(ctx context.Context, filePath string) error {
|
|
// Send a sync request to the master via the execute queue
|
|
req := buildExecuteRequest("sync", filePath, "", filePath, "", "master", "")
|
|
if err := w.client.PushData(ctx, KeyDataExecute, "execute", req, w.ID); err != nil {
|
|
return fmt.Errorf("failed to send sync request: %w", err)
|
|
}
|
|
|
|
// Wait a short time for the sync to complete
|
|
syncCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-syncCtx.Done():
|
|
return fmt.Errorf("timeout waiting for file sync of %s", filePath)
|
|
case <-ticker.C:
|
|
if _, err := os.Stat(filePath); err == nil {
|
|
w.printer.Success("File %s synced successfully", filePath)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Execute Listener (per-worker execute queue)
|
|
// =============================================================================
|
|
|
|
// executeListenerLoop polls the per-worker execute queue for requests routed by the master.
|
|
func (w *Worker) executeListenerLoop(ctx context.Context) {
|
|
key := KeyDataExecuteForWorker(w.ID)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
envelope, err := w.client.PopData(ctx, key, TaskPollTimeout)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
w.printer.Warning("Execute listener error: %s", err)
|
|
time.Sleep(time.Second)
|
|
continue
|
|
}
|
|
if envelope == nil {
|
|
continue
|
|
}
|
|
w.processExecuteRequest(ctx, envelope)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processExecuteRequest handles an execute request received on the worker's execute queue.
|
|
func (w *Worker) processExecuteRequest(ctx context.Context, envelope *DataEnvelope) {
|
|
var req ExecuteRequest
|
|
if err := json.Unmarshal(envelope.Data, &req); err != nil {
|
|
w.printer.Warning("Failed to unmarshal execute request: %s", err)
|
|
return
|
|
}
|
|
|
|
executeType := req.ExecuteType
|
|
if executeType == "" {
|
|
executeType = req.Action
|
|
}
|
|
|
|
w.printer.Info("Processing execute request: type=%s from=%s", terminal.Yellow(executeType), terminal.Cyan(envelope.WorkerID))
|
|
|
|
switch executeType {
|
|
case "func":
|
|
expr := req.Data
|
|
if expr == "" {
|
|
expr = req.Expr
|
|
}
|
|
execCtx := executor.BuildBuiltinVariables(w.config, nil)
|
|
registry := functions.NewRegistry()
|
|
if _, err := registry.Execute(expr, execCtx); err != nil {
|
|
w.printer.Warning("Execute func failed: %s (expr: %s)", err, expr)
|
|
}
|
|
|
|
case "run":
|
|
workflow := req.Data
|
|
if workflow == "" {
|
|
workflow = req.Workflow
|
|
}
|
|
task := NewTask(uuid.NewString()[:8], workflow, "module", req.Target, nil)
|
|
result := w.executeTask(ctx, task)
|
|
if result.Status == TaskStatusFailed {
|
|
w.printer.Warning("Execute run failed: %s", result.Error)
|
|
}
|
|
|
|
case "bash":
|
|
command := req.Data
|
|
if command == "" {
|
|
command = req.Expr
|
|
}
|
|
// @NOTE: This is intentional - execute requests come from trusted workflow YAML files
|
|
// via the distributed system. The master routes requests from run_on_worker() calls.
|
|
cmd := exec.CommandContext(ctx, "sh", "-c", command)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
w.printer.Warning("Execute bash failed: %s (output: %s)", err, string(output))
|
|
}
|
|
|
|
default:
|
|
w.printer.Warning("Unknown execute type: %s", executeType)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
// countWorkflowSteps counts the total number of steps in a workflow.
|
|
// For flows, it sums the steps of all referenced modules.
|
|
func countWorkflowSteps(workflow *core.Workflow, loader *parser.Loader) int {
|
|
if workflow.IsFlow() && loader != nil {
|
|
total := 0
|
|
for _, mod := range workflow.Modules {
|
|
m, err := loader.LoadWorkflow(mod.Name)
|
|
if err == nil {
|
|
total += len(m.Steps)
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
return len(workflow.Steps)
|
|
}
|
|
|
|
// computeWorkspace derives a workspace name from the target using heuristic analysis.
|
|
func computeWorkspace(target string) string {
|
|
info, err := heuristics.Analyze(target, "basic")
|
|
if err == nil && info != nil && info.RootDomain != "" {
|
|
return info.RootDomain
|
|
}
|
|
return target
|
|
}
|
|
|
|
// =============================================================================
|
|
// Data Queue Methods - Send data to master via Redis
|
|
// =============================================================================
|
|
|
|
// SendRunData sends run data to the master via Redis queue
|
|
func (w *Worker) SendRunData(ctx context.Context, run *database.Run) error {
|
|
return w.client.PushData(ctx, KeyDataRuns, "run", run, w.ID)
|
|
}
|
|
|
|
// SendStepResult sends step result data to the master via Redis queue
|
|
func (w *Worker) SendStepResult(ctx context.Context, step *database.StepResult) error {
|
|
return w.client.PushData(ctx, KeyDataSteps, "step", step, w.ID)
|
|
}
|
|
|
|
// SendEventLog sends event log data to the master via Redis queue
|
|
func (w *Worker) SendEventLog(ctx context.Context, eventLog *database.EventLog) error {
|
|
return w.client.PushData(ctx, KeyDataEvents, "event", eventLog, w.ID)
|
|
}
|
|
|
|
// SendArtifact sends artifact data to the master via Redis queue
|
|
func (w *Worker) SendArtifact(ctx context.Context, artifact *database.Artifact) error {
|
|
return w.client.PushData(ctx, KeyDataArtifacts, "artifact", artifact, w.ID)
|
|
}
|
|
|
|
// SendExecuteRequest sends an execute request to the master via Redis queue
|
|
func (w *Worker) SendExecuteRequest(ctx context.Context, action, expr, workflow, target, params, targetRole, targetScope string) error {
|
|
req := buildExecuteRequest(action, expr, workflow, target, params, targetRole, targetScope)
|
|
return w.client.PushData(ctx, KeyDataExecute, "execute", req, w.ID)
|
|
}
|
|
|
|
// buildExecuteRequest creates an ExecuteRequest with both new and legacy fields populated.
|
|
func buildExecuteRequest(action, expr, workflow, target, params, targetRole, targetScope string) *ExecuteRequest {
|
|
if targetRole == "" {
|
|
targetRole = "master"
|
|
}
|
|
data := expr
|
|
if action == "run" {
|
|
data = workflow
|
|
}
|
|
return &ExecuteRequest{
|
|
ExecuteType: action,
|
|
TargetRole: targetRole,
|
|
Data: data,
|
|
Target: target,
|
|
Params: params,
|
|
TargetScope: targetScope,
|
|
// Legacy fields for backward compatibility
|
|
Action: action,
|
|
Expr: expr,
|
|
Workflow: workflow,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Distributed Hooks Registration
|
|
// =============================================================================
|
|
|
|
// registerDistributedHooks registers callbacks for database writes to use Redis queues
|
|
func (w *Worker) registerDistributedHooks() {
|
|
w.unregisterHooks = RegisterDistributedHooksFromClient(w.client, w.ID)
|
|
w.printer.Info("Registered distributed hooks for database writes")
|
|
}
|
|
|
|
// unregisterDistributedHooks removes the distributed hooks
|
|
func (w *Worker) unregisterDistributedHooks() {
|
|
if w.unregisterHooks != nil {
|
|
w.unregisterHooks()
|
|
}
|
|
w.printer.Info("Unregistered distributed hooks")
|
|
}
|
|
|
|
// RegisterDistributedHooksFromClient registers distributed hooks using a bare
|
|
// Client and workerID, without requiring the full Worker struct. This is useful
|
|
// for one-shot operations (e.g., worker eval) that need run_on_master() routing
|
|
// without the full worker lifecycle (heartbeat, task loop, master registration).
|
|
// Returns a cleanup function that unregisters all hooks.
|
|
func RegisterDistributedHooksFromClient(client *Client, workerID string) func() {
|
|
hooks := &database.DistributedHooks{
|
|
SendRun: func(ctx context.Context, run *database.Run) error {
|
|
return client.PushData(ctx, KeyDataRuns, "run", run, workerID)
|
|
},
|
|
SendStepResult: func(ctx context.Context, step *database.StepResult) error {
|
|
return client.PushData(ctx, KeyDataSteps, "step", step, workerID)
|
|
},
|
|
SendEventLog: func(ctx context.Context, event *database.EventLog) error {
|
|
return client.PushData(ctx, KeyDataEvents, "event", event, workerID)
|
|
},
|
|
SendArtifact: func(ctx context.Context, artifact *database.Artifact) error {
|
|
return client.PushData(ctx, KeyDataArtifacts, "artifact", artifact, workerID)
|
|
},
|
|
ShouldUseRedis: func() bool {
|
|
return config.ShouldUseRedisDataQueues()
|
|
},
|
|
}
|
|
database.RegisterDistributedHooks(hooks)
|
|
|
|
// Register execute hooks for run_on_master() and run_on_worker() functions
|
|
execHooks := &functions.ExecuteHooks{
|
|
SendExecuteRequest: func(ctx context.Context, action, expr, workflow, target, params, targetRole, targetScope string) error {
|
|
req := buildExecuteRequest(action, expr, workflow, target, params, targetRole, targetScope)
|
|
return client.PushData(ctx, KeyDataExecute, "execute", req, workerID)
|
|
},
|
|
ShouldUseRedis: func() bool {
|
|
return config.ShouldUseRedisDataQueues()
|
|
},
|
|
ResolveWorkerSSH: func(ctx context.Context, identifier string) (*functions.WorkerSSHInfo, error) {
|
|
// Try by ID first
|
|
w, err := client.GetWorker(ctx, identifier)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to look up worker %q: %w", identifier, err)
|
|
}
|
|
// Try by alias if not found by ID
|
|
if w == nil {
|
|
w, err = client.GetWorkerByAlias(ctx, identifier)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to look up worker by alias %q: %w", identifier, err)
|
|
}
|
|
}
|
|
// Try by PublicIP if still not found
|
|
if w == nil {
|
|
workers, err := client.GetAllWorkers(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list workers: %w", err)
|
|
}
|
|
for _, cand := range workers {
|
|
if cand.PublicIP == identifier || cand.IPAddress == identifier {
|
|
w = cand
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if w == nil {
|
|
return nil, fmt.Errorf("worker %q not found", identifier)
|
|
}
|
|
if !w.SSHEnabled {
|
|
return nil, fmt.Errorf("worker %q does not have SSH enabled", identifier)
|
|
}
|
|
host := w.PublicIP
|
|
if host == "" {
|
|
host = w.IPAddress
|
|
}
|
|
return &functions.WorkerSSHInfo{
|
|
ID: w.ID,
|
|
Host: host,
|
|
User: "root",
|
|
KeyPath: w.SSHKeysPath,
|
|
Alias: w.Alias,
|
|
Port: 22,
|
|
}, nil
|
|
},
|
|
}
|
|
functions.RegisterExecuteHooks(execHooks)
|
|
|
|
return func() {
|
|
database.UnregisterDistributedHooks()
|
|
functions.UnregisterExecuteHooks()
|
|
config.SetWorkerMode(false, "")
|
|
}
|
|
}
|