mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-21 23:22:30 +02:00
1273 lines
38 KiB
Go
1273 lines
38 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"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/distributed"
|
|
"github.com/j3ssie/osmedeus/v5/internal/executor"
|
|
"github.com/j3ssie/osmedeus/v5/internal/logger"
|
|
"github.com/j3ssie/osmedeus/v5/internal/parser"
|
|
"github.com/j3ssie/osmedeus/v5/internal/terminal"
|
|
"github.com/spf13/cobra"
|
|
"go.uber.org/zap"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var (
|
|
flowName string
|
|
moduleNames []string
|
|
targets []string
|
|
targetFile string
|
|
paramFlags []string
|
|
paramsFile string
|
|
workspacePath string
|
|
dryRun bool
|
|
threadsHold int
|
|
runTactic string
|
|
excludeModules []string
|
|
spaceName string
|
|
workspacesFolder string
|
|
heuristicsCheck string
|
|
distributedRun bool
|
|
redisURLRun string
|
|
concurrency int
|
|
repeatRun bool
|
|
repeatWaitTime string
|
|
runTimeout string
|
|
stdModule bool
|
|
emptyTarget bool
|
|
progressBar bool
|
|
disableWorkflowState bool
|
|
|
|
// explicitFlags tracks which CLI flags were explicitly set by the user
|
|
// Used to determine precedence when applying workflow preferences
|
|
explicitFlags map[string]bool
|
|
)
|
|
|
|
// runCmd represents the run command
|
|
var runCmd = &cobra.Command{
|
|
Use: "run",
|
|
Short: "Execute a workflow",
|
|
Long: UsageRun(),
|
|
RunE: runRun,
|
|
}
|
|
|
|
func init() {
|
|
runCmd.Flags().StringVarP(&flowName, "flow", "f", "", "flow workflow name to execute")
|
|
runCmd.Flags().StringArrayVarP(&moduleNames, "module", "m", nil, "module workflow(s) to execute (can specify multiple)")
|
|
runCmd.Flags().StringArrayVarP(&targets, "target", "t", nil, "target(s) to run against (can be specified multiple times)")
|
|
runCmd.Flags().StringVarP(&targetFile, "target-file", "T", "", "file containing targets (one per line)")
|
|
runCmd.Flags().StringArrayVarP(¶mFlags, "params", "p", nil, "additional parameters (key=value format)")
|
|
runCmd.Flags().StringVarP(¶msFile, "params-file", "P", "", "file containing parameters (JSON or YAML key:value pairs)")
|
|
runCmd.Flags().StringVarP(&workspacePath, "workspace", "w", "", "custom workspace path")
|
|
runCmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be executed without running commands")
|
|
runCmd.Flags().IntVar(&threadsHold, "threads-hold", 0, "override thread count (0 = use tactic default)")
|
|
runCmd.Flags().IntVarP(&concurrency, "concurrency", "c", 1, "number of targets to run concurrently")
|
|
runCmd.Flags().StringVarP(&runTactic, "tactic", "B", "default", "run tactic: aggressive, default, gently")
|
|
runCmd.Flags().StringArrayVarP(&excludeModules, "exclude", "x", nil, "module(s) to exclude from execution (can be specified multiple times)")
|
|
runCmd.Flags().StringVarP(&spaceName, "space", "S", "", "override {{TargetSpace}} variable")
|
|
runCmd.Flags().StringVarP(&workspacesFolder, "workspaces-folder", "W", "", "override {{Workspaces}} variable")
|
|
runCmd.Flags().StringVar(&heuristicsCheck, "heuristics-check", "basic", "heuristics check level: none, basic, advanced")
|
|
runCmd.Flags().BoolVarP(&distributedRun, "distributed-run", "D", false, "submit run to distributed worker queue (requires Redis)")
|
|
runCmd.Flags().StringVar(&redisURLRun, "redis-url", "", "Redis connection URL for distributed mode (overrides settings)")
|
|
runCmd.Flags().BoolVar(&repeatRun, "repeat", false, "repeat run after completion")
|
|
runCmd.Flags().StringVar(&repeatWaitTime, "repeat-wait-time", "1h", "wait time between repeats (e.g., 30s, 20m, 10h, 1d)")
|
|
runCmd.Flags().StringVar(&runTimeout, "timeout", "", "run timeout (e.g., 2h, 3h, 1d)")
|
|
runCmd.Flags().BoolVar(&stdModule, "std-module", false, "read module YAML from stdin")
|
|
runCmd.Flags().BoolVar(&emptyTarget, "empty-target", false, "run without target (generates placeholder target)")
|
|
runCmd.Flags().BoolVarP(&progressBar, "progress-bar", "G", false, "show progress bar during execution (enables silent mode)")
|
|
runCmd.Flags().BoolVar(&disableWorkflowState, "disable-workflow-state", false, "disable writing workflow YAML to output directory")
|
|
}
|
|
|
|
// captureExplicitFlags records which CLI flags were explicitly set by the user
|
|
// This is used to determine precedence when applying workflow preferences
|
|
func captureExplicitFlags(cmd *cobra.Command) {
|
|
explicitFlags = make(map[string]bool)
|
|
|
|
// Run command flags
|
|
runFlagNames := []string{
|
|
"heuristics-check", "repeat", "repeat-wait-time",
|
|
}
|
|
for _, name := range runFlagNames {
|
|
if f := cmd.Flags().Lookup(name); f != nil {
|
|
explicitFlags[name] = f.Changed
|
|
}
|
|
}
|
|
|
|
// Global/persistent flags (from root command)
|
|
globalFlagNames := []string{
|
|
"silent", "disable-logging", "disable-notification", "ci-output-format",
|
|
}
|
|
for _, name := range globalFlagNames {
|
|
// Check both local and inherited persistent flags
|
|
if f := cmd.Flags().Lookup(name); f != nil {
|
|
explicitFlags[name] = f.Changed
|
|
} else if f := cmd.InheritedFlags().Lookup(name); f != nil {
|
|
explicitFlags[name] = f.Changed
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyWorkflowPreferences applies workflow preferences to CLI variables
|
|
// Only applies if the corresponding CLI flag was NOT explicitly set by the user
|
|
func applyWorkflowPreferences(prefs *core.Preferences, printer *terminal.Printer) {
|
|
if prefs == nil {
|
|
return
|
|
}
|
|
|
|
applied := []string{}
|
|
|
|
// disable_notifications -> disableNotification (global)
|
|
if prefs.DisableNotifications != nil && !explicitFlags["disable-notification"] {
|
|
disableNotification = *prefs.DisableNotifications
|
|
if *prefs.DisableNotifications {
|
|
applied = append(applied, "disable_notifications")
|
|
}
|
|
}
|
|
|
|
// disable_logging -> disableLogging (global)
|
|
if prefs.DisableLogging != nil && !explicitFlags["disable-logging"] {
|
|
disableLogging = *prefs.DisableLogging
|
|
if *prefs.DisableLogging {
|
|
applied = append(applied, "disable_logging")
|
|
}
|
|
}
|
|
|
|
// heuristics_check -> heuristicsCheck
|
|
if prefs.HeuristicsCheck != nil && !explicitFlags["heuristics-check"] {
|
|
heuristicsCheck = *prefs.HeuristicsCheck
|
|
applied = append(applied, "heuristics_check="+*prefs.HeuristicsCheck)
|
|
}
|
|
|
|
// ci_output_format -> ciOutputFormat (global)
|
|
if prefs.CIOutputFormat != nil && !explicitFlags["ci-output-format"] {
|
|
ciOutputFormat = *prefs.CIOutputFormat
|
|
if *prefs.CIOutputFormat {
|
|
terminal.SetCIMode(true)
|
|
terminal.SetColorEnabled(false)
|
|
applied = append(applied, "ci_output_format")
|
|
}
|
|
}
|
|
|
|
// silent -> silent (global)
|
|
if prefs.Silent != nil && !explicitFlags["silent"] {
|
|
silent = *prefs.Silent
|
|
if *prefs.Silent {
|
|
applied = append(applied, "silent")
|
|
}
|
|
}
|
|
|
|
// repeat -> repeatRun
|
|
if prefs.Repeat != nil && !explicitFlags["repeat"] {
|
|
repeatRun = *prefs.Repeat
|
|
if *prefs.Repeat {
|
|
applied = append(applied, "repeat")
|
|
}
|
|
}
|
|
|
|
// repeat_wait_time -> repeatWaitTime
|
|
if prefs.RepeatWaitTime != nil && !explicitFlags["repeat-wait-time"] {
|
|
repeatWaitTime = *prefs.RepeatWaitTime
|
|
applied = append(applied, "repeat_wait_time="+*prefs.RepeatWaitTime)
|
|
}
|
|
|
|
// Log applied preferences if verbose
|
|
if len(applied) > 0 && verbose {
|
|
printer.Info("Applied workflow preferences: %s", strings.Join(applied, ", "))
|
|
}
|
|
}
|
|
|
|
func runRun(cmd *cobra.Command, args []string) error {
|
|
printer := terminal.NewPrinter()
|
|
|
|
// Capture which CLI flags were explicitly set (for preference merging)
|
|
captureExplicitFlags(cmd)
|
|
|
|
// Print greeting message (skip in CI mode)
|
|
if !ciOutputFormat {
|
|
printer.Println("%s Initiating Osmedeus %s - Crafted with %s by %s",
|
|
terminal.Yellow(terminal.SymbolLightning),
|
|
terminal.Cyan(core.VERSION),
|
|
terminal.Red("<3"),
|
|
terminal.Yellow(core.AUTHOR))
|
|
printer.Newline()
|
|
}
|
|
|
|
// Auto-enable no-log when progress bar is enabled
|
|
if progressBar {
|
|
// Re-initialize logger with silent mode to suppress log output
|
|
logCfg := logger.DefaultConfig()
|
|
logCfg.Level = "error"
|
|
logCfg.Silent = true
|
|
_ = logger.Init(logCfg)
|
|
}
|
|
|
|
// Get logger after potential re-initialization
|
|
log := logger.Get()
|
|
|
|
// Validate flags
|
|
if flowName == "" && len(moduleNames) == 0 && !stdModule {
|
|
printer.Warning("No workflow specified. Using default flow: general")
|
|
printer.Info("Tip: Use -f <flow_name> or -m <module_name> to select a workflow")
|
|
fmt.Println()
|
|
flowName = "general"
|
|
}
|
|
if flowName != "" && len(moduleNames) > 0 {
|
|
return fmt.Errorf("only one of --flow or --module can be specified")
|
|
}
|
|
if stdModule && (flowName != "" || len(moduleNames) > 0) {
|
|
return fmt.Errorf("--std-module cannot be combined with --flow or --module")
|
|
}
|
|
|
|
// Parse timeout duration
|
|
var timeoutDuration time.Duration
|
|
if runTimeout != "" {
|
|
var err error
|
|
timeoutDuration, err = parseRunDuration(runTimeout)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid timeout: %w", err)
|
|
}
|
|
printer.Info("Run timeout: %s", runTimeout)
|
|
}
|
|
|
|
// Parse repeat wait time
|
|
var waitDuration time.Duration
|
|
if repeatRun {
|
|
var err error
|
|
waitDuration, err = parseRunDuration(repeatWaitTime)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid repeat-wait-time: %w", err)
|
|
}
|
|
printer.Info("Repeat mode enabled, wait time: %s", repeatWaitTime)
|
|
}
|
|
|
|
cfg := config.Get()
|
|
if cfg == nil {
|
|
return fmt.Errorf("configuration not loaded")
|
|
}
|
|
|
|
// Ensure external binaries are in PATH at runtime
|
|
// This helps when users haven't reloaded their shell after installation
|
|
ensureExternalBinariesInPath(cfg)
|
|
|
|
// Collect all targets from flags, file, and stdin
|
|
log.Debug("Collecting targets",
|
|
zap.Strings("flag_targets", targets),
|
|
zap.String("target_file", targetFile),
|
|
)
|
|
allTargets, err := collectTargets()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Debug("Targets collected",
|
|
zap.Int("count", len(allTargets)),
|
|
zap.Strings("targets", allTargets),
|
|
)
|
|
|
|
if len(allTargets) == 0 {
|
|
if emptyTarget {
|
|
// Generate placeholder target
|
|
allTargets = []string{generateEmptyTarget()}
|
|
printer.Info("Using generated target: %s", allTargets[0])
|
|
} else {
|
|
return fmt.Errorf("no targets specified. Use -t, -T, pipe targets via stdin, or use --empty-target")
|
|
}
|
|
}
|
|
|
|
// Handle distributed run mode
|
|
if distributedRun {
|
|
return runDistributedRun(cfg, allTargets, printer)
|
|
}
|
|
|
|
loader := parser.NewLoader(cfg.WorkflowsPath)
|
|
|
|
// Execute workflow for each target (with concurrency)
|
|
if concurrency <= 0 {
|
|
concurrency = 1
|
|
}
|
|
|
|
// Setup signal handling for graceful shutdown
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
|
|
// Main run loop (handles repeat)
|
|
iteration := 0
|
|
for {
|
|
iteration++
|
|
if repeatRun && iteration > 1 {
|
|
printer.Section(fmt.Sprintf("Repeat Iteration %d", iteration))
|
|
}
|
|
|
|
// Create context with timeout if specified
|
|
var ctx context.Context
|
|
var cancel context.CancelFunc
|
|
if timeoutDuration > 0 {
|
|
ctx, cancel = context.WithTimeout(context.Background(), timeoutDuration)
|
|
} else {
|
|
ctx, cancel = context.WithCancel(context.Background())
|
|
}
|
|
|
|
// Handle interrupt signals in goroutine
|
|
go func() {
|
|
select {
|
|
case <-sigChan:
|
|
log.Warn("Received interrupt signal, cancelling...")
|
|
printer.Warning("Interrupt received, cancelling run...")
|
|
cancel()
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
|
|
var lastErr error
|
|
|
|
if stdModule {
|
|
// Stdin module mode - read workflow from stdin
|
|
workflow, err := readWorkflowFromStdin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printer.Success("Workflow loaded from stdin: %s (%s)", workflow.Name, terminal.TypeBadge(string(workflow.Kind)))
|
|
|
|
// Apply workflow preferences (if any) - CLI flags take precedence
|
|
applyWorkflowPreferences(workflow.Preferences, printer)
|
|
|
|
if workflow.IsFlow() {
|
|
return fmt.Errorf("--std-module only supports module workflows, got flow")
|
|
}
|
|
|
|
// Execute for all targets (nil loader since flows not supported for stdin)
|
|
lastErr = executeSingleWorkflowDirect(ctx, workflow, allTargets, cfg, printer, log, nil)
|
|
} else if flowName != "" {
|
|
// Flow mode - single workflow
|
|
lastErr = executeSingleWorkflow(ctx, loader, flowName, allTargets, cfg, printer, log)
|
|
} else {
|
|
// Module mode - run each module in sequence
|
|
for i, moduleName := range moduleNames {
|
|
if len(moduleNames) > 1 {
|
|
printer.Section(fmt.Sprintf("Module %d/%d: %s", i+1, len(moduleNames), moduleName))
|
|
}
|
|
|
|
moduleErr := executeSingleWorkflow(ctx, loader, moduleName, allTargets, cfg, printer, log)
|
|
if moduleErr != nil {
|
|
if ctx.Err() != nil {
|
|
// Context cancelled (timeout or interrupt)
|
|
lastErr = moduleErr
|
|
break
|
|
}
|
|
printer.Error("Module %s failed: %s", moduleName, moduleErr)
|
|
lastErr = moduleErr
|
|
// Continue to next module
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check context error before calling cancel (cancel sets Canceled)
|
|
ctxErr := ctx.Err()
|
|
cancel()
|
|
|
|
// Check if timeout was exceeded
|
|
if ctxErr == context.DeadlineExceeded {
|
|
printer.Error("Run timed out after %s", runTimeout)
|
|
return fmt.Errorf("run timed out after %s", runTimeout)
|
|
}
|
|
|
|
// Check if interrupted by signal (not by our own cancel)
|
|
if ctxErr == context.Canceled {
|
|
return fmt.Errorf("run cancelled")
|
|
}
|
|
|
|
// Handle repeat
|
|
if !repeatRun {
|
|
return lastErr
|
|
}
|
|
|
|
printer.Info("Run iteration %d completed. Waiting %s before next iteration...", iteration, repeatWaitTime)
|
|
printer.Info("Press Ctrl+C to stop repeat mode")
|
|
|
|
// Wait with interrupt handling
|
|
select {
|
|
case <-time.After(waitDuration):
|
|
// Continue to next iteration
|
|
case <-sigChan:
|
|
printer.Info("Interrupt received, stopping repeat mode")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// executeSingleWorkflow loads and executes a single workflow against all targets
|
|
func executeSingleWorkflow(ctx context.Context, loader *parser.Loader, workflowName string, allTargets []string, cfg *config.Config, printer *terminal.Printer, log *zap.Logger) error {
|
|
log.Debug("Loading workflow",
|
|
zap.String("workflow_name", workflowName),
|
|
)
|
|
|
|
var sp *terminal.Spinner
|
|
if showSpinner {
|
|
sp = terminal.LoadingSpinner("Loading workflow " + workflowName)
|
|
sp.Start()
|
|
}
|
|
|
|
workflow, err := loader.LoadWorkflow(workflowName)
|
|
if sp != nil {
|
|
sp.Stop()
|
|
}
|
|
|
|
if err != nil {
|
|
printer.Error("Failed to load workflow: %s", err)
|
|
return fmt.Errorf("failed to load workflow: %w", err)
|
|
}
|
|
|
|
printer.Success("Workflow loaded: %s (%s)", workflow.Name, terminal.TypeBadge(string(workflow.Kind)))
|
|
log.Info("Workflow loaded",
|
|
zap.String("name", workflow.Name),
|
|
zap.String("kind", string(workflow.Kind)),
|
|
)
|
|
|
|
// Apply workflow preferences (if any) - CLI flags take precedence
|
|
applyWorkflowPreferences(workflow.Preferences, printer)
|
|
|
|
// Show target count and concurrency
|
|
if len(allTargets) > 1 {
|
|
printer.Info("Running against %d targets (concurrency: %d)", len(allTargets), concurrency)
|
|
}
|
|
|
|
log.Debug("Starting concurrent execution",
|
|
zap.Int("target_count", len(allTargets)),
|
|
zap.Int("concurrency", concurrency),
|
|
zap.String("tactic", runTactic),
|
|
zap.Bool("dry_run", dryRun),
|
|
)
|
|
|
|
results, lastErr := executeRunsConcurrentlyWithContext(ctx, workflow, allTargets, cfg, concurrency, loader)
|
|
|
|
// Print summary for multiple targets
|
|
if len(allTargets) > 1 {
|
|
printMultiTargetSummary(results, len(allTargets))
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// executeRunsConcurrentlyWithContext runs workflows for multiple targets with controlled concurrency and context
|
|
func executeRunsConcurrentlyWithContext(ctx context.Context, workflow *core.Workflow, targets []string, cfg *config.Config, maxConcurrency int, loader *parser.Loader) ([]*core.WorkflowResult, error) {
|
|
printer := terminal.NewPrinter()
|
|
|
|
type scanResult struct {
|
|
index int
|
|
result *core.WorkflowResult
|
|
err error
|
|
}
|
|
|
|
sem := make(chan struct{}, maxConcurrency) // Semaphore for concurrency control
|
|
results := make(chan scanResult, len(targets))
|
|
var wg sync.WaitGroup
|
|
|
|
for i, target := range targets {
|
|
wg.Add(1)
|
|
go func(idx int, t string) {
|
|
defer wg.Done()
|
|
|
|
// Check if context is cancelled
|
|
select {
|
|
case <-ctx.Done():
|
|
results <- scanResult{index: idx, result: nil, err: ctx.Err()}
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Acquire semaphore
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
|
|
if len(targets) > 1 {
|
|
printer.Info("[%d/%d] Starting: %s", idx+1, len(targets), t)
|
|
}
|
|
|
|
result, err := executeRunForTargetWithContext(ctx, workflow, t, cfg, loader)
|
|
results <- scanResult{index: idx, result: result, err: err}
|
|
}(i, target)
|
|
}
|
|
|
|
// Close results channel when all done
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
// Collect results in order
|
|
allResults := make([]*core.WorkflowResult, len(targets))
|
|
var lastErr error
|
|
|
|
for r := range results {
|
|
allResults[r.index] = r.result
|
|
if r.err != nil {
|
|
printer.Error("Failed for target %s: %s", targets[r.index], r.err)
|
|
lastErr = r.err
|
|
}
|
|
}
|
|
|
|
return allResults, lastErr
|
|
}
|
|
|
|
// executeRunForTargetWithContext executes the workflow for a single target with context support
|
|
func executeRunForTargetWithContext(ctx context.Context, workflow *core.Workflow, target string, cfg *config.Config, loader *parser.Loader) (*core.WorkflowResult, error) {
|
|
log := logger.Get()
|
|
|
|
log.Debug("Starting run for target",
|
|
zap.String("target", target),
|
|
zap.String("workflow", workflow.Name),
|
|
)
|
|
|
|
// Parse parameters: file params first, then CLI params override
|
|
params := make(map[string]string)
|
|
|
|
// Load params from file if specified
|
|
if paramsFile != "" {
|
|
fileParams, err := loadParamsFromFile(paramsFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load params file: %w", err)
|
|
}
|
|
for k, v := range fileParams {
|
|
params[k] = v
|
|
}
|
|
log.Debug("Loaded params from file",
|
|
zap.String("file", paramsFile),
|
|
zap.Int("count", len(fileParams)),
|
|
)
|
|
}
|
|
|
|
// CLI params (-p) override file params
|
|
for k, v := range parseParams(paramFlags) {
|
|
params[k] = v
|
|
}
|
|
|
|
// Set built-in params (override user params)
|
|
params["target"] = target
|
|
params["target_file"] = targetFile
|
|
params["tactic"] = runTactic
|
|
params["threads_hold"] = fmt.Sprintf("%d", threadsHold)
|
|
params["exclude_modules"] = strings.Join(excludeModules, ",")
|
|
params["space_name"] = spaceName
|
|
params["workspaces_folder"] = workspacesFolder
|
|
params["heuristics_check"] = heuristicsCheck
|
|
|
|
log.Debug("Run parameters configured",
|
|
zap.String("target", target),
|
|
zap.String("tactic", runTactic),
|
|
zap.Int("threads_hold", threadsHold),
|
|
zap.Strings("exclude_modules", excludeModules),
|
|
zap.Int("param_count", len(params)),
|
|
)
|
|
|
|
// Check if context is already cancelled
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
// Create run record in database (skip for dry-run)
|
|
var runID string
|
|
if !dryRun {
|
|
runID = createCLIRunRecord(ctx, cfg, workflow, target, params)
|
|
}
|
|
|
|
// Create executor
|
|
log.Debug("Creating executor",
|
|
zap.Bool("dry_run", dryRun),
|
|
zap.Bool("spinner", showSpinner),
|
|
zap.Bool("verbose", verbose),
|
|
zap.Bool("progress_bar", progressBar),
|
|
)
|
|
exec := executor.NewExecutor()
|
|
exec.SetDryRun(dryRun)
|
|
exec.SetDisableWorkflowState(disableWorkflowState)
|
|
exec.SetSpinner(showSpinner)
|
|
exec.SetVerbose(verbose) // Show actual step output in verbose mode
|
|
exec.SetSilent(silent) // Hide step output in silent mode
|
|
if loader != nil {
|
|
exec.SetLoader(loader) // Set loader for flow execution (loading nested modules)
|
|
}
|
|
|
|
// Set up database progress tracking
|
|
if runID != "" {
|
|
exec.SetDBRunID(runID)
|
|
exec.SetOnStepCompleted(func(stepCtx context.Context, dbRunID string) {
|
|
_ = database.IncrementRunCompletedSteps(stepCtx, dbRunID)
|
|
})
|
|
}
|
|
|
|
// Create progress bar if enabled
|
|
var pb *terminal.ProgressBar
|
|
if progressBar && !dryRun {
|
|
pb = terminal.NewProgressBar(len(workflow.Steps), workflow.Name)
|
|
exec.SetProgressBar(pb)
|
|
}
|
|
|
|
// Print dry-run header if enabled
|
|
if dryRun {
|
|
// Calculate thread values
|
|
threads, baseThreads := cfg.GetThreads(runTactic)
|
|
if threadsHold > 0 {
|
|
threads = threadsHold
|
|
baseThreads = threadsHold / 2
|
|
if baseThreads < 1 {
|
|
baseThreads = 1
|
|
}
|
|
}
|
|
|
|
separator := strings.Repeat("═", 40)
|
|
|
|
fmt.Println()
|
|
fmt.Printf("%s %s %s\n", terminal.Yellow("⚠"), terminal.BoldYellow("DRY-RUN Mode"), terminal.Gray("- No commands will be executed"))
|
|
fmt.Println(terminal.Yellow(separator))
|
|
fmt.Printf("%s Workflow: %s\n", terminal.Cyan("│"), terminal.Bold(workflow.Name))
|
|
fmt.Printf("%s Target: %s\n", terminal.Cyan("│"), terminal.Cyan(target))
|
|
fmt.Printf("%s Steps: %s\n", terminal.Cyan("│"), terminal.Gray(fmt.Sprintf("%d", len(workflow.Steps))))
|
|
fmt.Printf("%s Tactic: %s\n", terminal.Cyan("│"), terminal.Gray(runTactic))
|
|
fmt.Println()
|
|
fmt.Printf("%s %s\n", terminal.Cyan("✦"), terminal.Bold("Builtin Variables"))
|
|
fmt.Printf(" %s BaseFolder: %s\n", terminal.Gray("│"), terminal.Gray(cfg.BaseFolder))
|
|
fmt.Printf(" %s Binaries: %s\n", terminal.Gray("│"), terminal.Gray(cfg.BinariesPath))
|
|
fmt.Printf(" %s Data: %s\n", terminal.Gray("│"), terminal.Gray(cfg.DataPath))
|
|
fmt.Printf(" %s Workspaces: %s\n", terminal.Gray("│"), terminal.Gray(cfg.WorkspacesPath))
|
|
fmt.Printf(" %s Output: %s\n", terminal.Gray("│"), terminal.Gray(cfg.WorkspacesPath+"/"+target))
|
|
fmt.Printf(" %s threads: %s\n", terminal.Gray("│"), terminal.Gray(fmt.Sprintf("%d", threads)))
|
|
fmt.Printf(" %s baseThreads: %s\n", terminal.Gray("│"), terminal.Gray(fmt.Sprintf("%d", baseThreads)))
|
|
fmt.Printf(" %s Today: %s\n", terminal.Gray("│"), terminal.Gray(time.Now().Format("2006-01-02")))
|
|
fmt.Println(terminal.Yellow(separator))
|
|
fmt.Println()
|
|
}
|
|
|
|
// Execute workflow
|
|
log.Debug("Executing workflow",
|
|
zap.String("workflow", workflow.Name),
|
|
zap.String("kind", string(workflow.Kind)),
|
|
zap.String("target", target),
|
|
zap.Bool("is_flow", workflow.IsFlow()),
|
|
)
|
|
var result *core.WorkflowResult
|
|
var err error
|
|
if workflow.IsFlow() {
|
|
result, err = exec.ExecuteFlow(ctx, workflow, params, cfg)
|
|
} else {
|
|
result, err = exec.ExecuteModule(ctx, workflow, params, cfg)
|
|
}
|
|
|
|
if err != nil {
|
|
// Abort progress bar on error
|
|
if pb != nil {
|
|
pb.Abort()
|
|
}
|
|
log.Error("Workflow execution failed",
|
|
zap.String("workflow", workflow.Name),
|
|
zap.String("target", target),
|
|
zap.Error(err),
|
|
)
|
|
// Update run status to failed in database
|
|
if runID != "" {
|
|
_ = database.UpdateRunStatus(ctx, runID, "failed", err.Error())
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Finish progress bar on success
|
|
if pb != nil {
|
|
pb.Finish(!silent) // Show output unless silent mode
|
|
}
|
|
|
|
log.Debug("Workflow execution completed",
|
|
zap.String("workflow", workflow.Name),
|
|
zap.String("target", target),
|
|
zap.String("status", string(result.Status)),
|
|
zap.Int("step_results", len(result.Steps)),
|
|
zap.Duration("duration", result.EndTime.Sub(result.StartTime)),
|
|
)
|
|
|
|
// Update run status to completed in database
|
|
if runID != "" {
|
|
_ = database.UpdateRunStatus(ctx, runID, "completed", "")
|
|
}
|
|
|
|
// Print result summary for this target (skip if progress bar was used - it shows its own summary)
|
|
if pb == nil {
|
|
printResultSummary(result)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// createCLIRunRecord creates a run record in the database for CLI executions
|
|
func createCLIRunRecord(ctx context.Context, cfg *config.Config, workflow *core.Workflow, target string, params map[string]string) string {
|
|
log := logger.Get()
|
|
|
|
// Connect to database
|
|
_, err := database.Connect(cfg)
|
|
if err != nil {
|
|
log.Debug("Failed to connect to database for run record", zap.Error(err))
|
|
return ""
|
|
}
|
|
|
|
// Migrate database schema if needed
|
|
if err := database.Migrate(ctx); err != nil {
|
|
log.Debug("Failed to migrate database for run record", zap.Error(err))
|
|
return ""
|
|
}
|
|
|
|
now := time.Now()
|
|
runID := uuid.New().String()
|
|
|
|
// Convert params to interface map
|
|
paramsInterface := make(map[string]interface{})
|
|
for k, v := range params {
|
|
paramsInterface[k] = v
|
|
}
|
|
|
|
run := &database.Run{
|
|
ID: uuid.New().String(),
|
|
RunID: runID,
|
|
WorkflowName: workflow.Name,
|
|
WorkflowKind: string(workflow.Kind),
|
|
Target: target,
|
|
Params: paramsInterface,
|
|
Status: "running",
|
|
TriggerType: "cli",
|
|
StartedAt: &now,
|
|
TotalSteps: len(workflow.Steps),
|
|
}
|
|
|
|
if err := database.CreateRun(ctx, run); err != nil {
|
|
log.Debug("Failed to create run record", zap.Error(err))
|
|
return ""
|
|
}
|
|
|
|
log.Debug("Created run record", zap.String("run_id", runID))
|
|
return runID
|
|
}
|
|
|
|
// collectTargets gathers targets from all input sources: flags, file, and stdin
|
|
// When stdModule is true, stdin is reserved for the workflow YAML, not targets
|
|
func collectTargets() ([]string, error) {
|
|
var allTargets []string
|
|
|
|
// 1. Add targets from -t flags
|
|
allTargets = append(allTargets, targets...)
|
|
|
|
// 2. Read targets from file if -T is provided
|
|
if targetFile != "" {
|
|
fileTargets, err := readTargetsFromFile(targetFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read target file: %w", err)
|
|
}
|
|
allTargets = append(allTargets, fileTargets...)
|
|
}
|
|
|
|
// 3. Read targets from stdin if piped (skip if --std-module is used - stdin is for workflow)
|
|
if !stdModule {
|
|
stdinTargets, err := readTargetsFromStdin()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read from stdin: %w", err)
|
|
}
|
|
allTargets = append(allTargets, stdinTargets...)
|
|
}
|
|
|
|
// Deduplicate and filter empty lines
|
|
return deduplicateTargets(allTargets), nil
|
|
}
|
|
|
|
// readTargetsFromFile reads targets from a file, one per line
|
|
func readTargetsFromFile(filepath string) ([]string, error) {
|
|
file, err := os.Open(filepath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
var result []string
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
result = append(result, line)
|
|
}
|
|
}
|
|
return result, scanner.Err()
|
|
}
|
|
|
|
// readTargetsFromStdin reads targets from stdin if data is piped
|
|
func readTargetsFromStdin() ([]string, error) {
|
|
stat, err := os.Stdin.Stat()
|
|
if err != nil {
|
|
return nil, nil // Ignore stat errors, just skip stdin
|
|
}
|
|
|
|
// Check if stdin has piped data (not a terminal)
|
|
if (stat.Mode() & os.ModeCharDevice) != 0 {
|
|
return nil, nil // No piped data
|
|
}
|
|
|
|
var result []string
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
result = append(result, line)
|
|
}
|
|
}
|
|
return result, scanner.Err()
|
|
}
|
|
|
|
// deduplicateTargets removes duplicates and empty strings
|
|
func deduplicateTargets(inputTargets []string) []string {
|
|
seen := make(map[string]bool)
|
|
var result []string
|
|
for _, t := range inputTargets {
|
|
t = strings.TrimSpace(t)
|
|
if t != "" && !seen[t] {
|
|
seen[t] = true
|
|
result = append(result, t)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// printMultiTargetSummary prints a summary for multiple target execution
|
|
func printMultiTargetSummary(results []*core.WorkflowResult, totalTargets int) {
|
|
succeeded := 0
|
|
failed := 0
|
|
for _, r := range results {
|
|
if r.Status == core.RunStatusCompleted {
|
|
succeeded++
|
|
} else {
|
|
failed++
|
|
}
|
|
}
|
|
skipped := totalTargets - len(results)
|
|
|
|
// CI mode: output JSON
|
|
if ciOutputFormat {
|
|
ciResults := make([]map[string]interface{}, 0, len(results))
|
|
for _, r := range results {
|
|
if r != nil {
|
|
ciResults = append(ciResults, formatResultForCI(r))
|
|
}
|
|
}
|
|
output := map[string]interface{}{
|
|
"type": "multi_target_summary",
|
|
"total_targets": totalTargets,
|
|
"succeeded": succeeded,
|
|
"failed": failed,
|
|
"skipped": skipped,
|
|
"results": ciResults,
|
|
}
|
|
jsonBytes, _ := json.MarshalIndent(output, "", " ")
|
|
fmt.Println(string(jsonBytes))
|
|
return
|
|
}
|
|
|
|
printer := terminal.NewPrinter()
|
|
printer.Section("Multi-Target Summary")
|
|
|
|
printer.KeyValue("Total Targets", fmt.Sprintf("%d", totalTargets))
|
|
printer.KeyValueColored("Succeeded", fmt.Sprintf("%d", succeeded), terminal.Green)
|
|
if failed > 0 {
|
|
printer.KeyValueColored("Failed", fmt.Sprintf("%d", failed), terminal.Red)
|
|
}
|
|
if skipped > 0 {
|
|
printer.KeyValueColored("Skipped", fmt.Sprintf("%d", skipped), terminal.Yellow)
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
// parseParams parses key=value parameter flags
|
|
func parseParams(flags []string) map[string]string {
|
|
params := make(map[string]string)
|
|
for _, flag := range flags {
|
|
parts := strings.SplitN(flag, "=", 2)
|
|
if len(parts) == 2 {
|
|
params[parts[0]] = parts[1]
|
|
}
|
|
}
|
|
return params
|
|
}
|
|
|
|
// loadParamsFromFile reads parameters from a JSON or YAML file
|
|
func loadParamsFromFile(path string) (map[string]string, error) {
|
|
log := logger.Get()
|
|
|
|
log.Debug("Loading params from file", zap.String("path", path))
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read params file: %w", err)
|
|
}
|
|
|
|
// Detect format by extension
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
|
|
var rawParams map[string]interface{}
|
|
|
|
switch ext {
|
|
case ".json":
|
|
if err := json.Unmarshal(data, &rawParams); err != nil {
|
|
return nil, fmt.Errorf("failed to parse JSON params file: %w", err)
|
|
}
|
|
case ".yaml", ".yml":
|
|
if err := yaml.Unmarshal(data, &rawParams); err != nil {
|
|
return nil, fmt.Errorf("failed to parse YAML params file: %w", err)
|
|
}
|
|
default:
|
|
// Try JSON first, then YAML
|
|
if err := json.Unmarshal(data, &rawParams); err != nil {
|
|
if err := yaml.Unmarshal(data, &rawParams); err != nil {
|
|
return nil, fmt.Errorf("failed to parse params file (tried JSON and YAML): %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert all values to strings
|
|
params := make(map[string]string)
|
|
for k, v := range rawParams {
|
|
switch val := v.(type) {
|
|
case string:
|
|
params[k] = val
|
|
case int, int64, float64:
|
|
params[k] = fmt.Sprintf("%v", val)
|
|
case bool:
|
|
params[k] = fmt.Sprintf("%v", val)
|
|
default:
|
|
// For complex types, marshal back to JSON string
|
|
jsonBytes, err := json.Marshal(val)
|
|
if err != nil {
|
|
params[k] = fmt.Sprintf("%v", val)
|
|
} else {
|
|
params[k] = string(jsonBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Debug("Params loaded from file",
|
|
zap.String("path", path),
|
|
zap.Int("count", len(params)),
|
|
)
|
|
|
|
return params, nil
|
|
}
|
|
|
|
// printResultSummary prints a summary of the workflow result
|
|
func printResultSummary(result *core.WorkflowResult) {
|
|
// Calculate step counts from Steps slice
|
|
var completedSteps, failedSteps, skippedSteps int
|
|
for _, step := range result.Steps {
|
|
switch step.Status {
|
|
case core.StepStatusSuccess:
|
|
completedSteps++
|
|
case core.StepStatusFailed:
|
|
failedSteps++
|
|
case core.StepStatusSkipped:
|
|
skippedSteps++
|
|
}
|
|
}
|
|
|
|
// Log execution summary to state execution log file
|
|
logger.Get().Info("Execution Summary",
|
|
zap.String("workflow", result.WorkflowName),
|
|
zap.String("run_id", result.RunID),
|
|
zap.String("target", result.Target),
|
|
zap.String("status", string(result.Status)),
|
|
zap.Duration("duration", result.EndTime.Sub(result.StartTime)),
|
|
zap.Int("total_steps", len(result.Steps)),
|
|
zap.Int("completed_steps", completedSteps),
|
|
zap.Int("failed_steps", failedSteps),
|
|
zap.Int("skipped_steps", skippedSteps),
|
|
)
|
|
|
|
// CI mode: output JSON
|
|
if ciOutputFormat {
|
|
output := formatResultForCI(result)
|
|
jsonBytes, _ := json.MarshalIndent(output, "", " ")
|
|
fmt.Println(string(jsonBytes))
|
|
return
|
|
}
|
|
|
|
printer := terminal.NewPrinter()
|
|
|
|
printer.Section("Execution Summary")
|
|
printer.KeyValue("Workflow", result.WorkflowName)
|
|
printer.KeyValue("Run ID", result.RunID)
|
|
printer.KeyValue("Target", result.Target)
|
|
printer.KeyValue("Status", terminal.StatusBadge(string(result.Status)))
|
|
printer.KeyValue("Duration", formatDuration(result.EndTime.Sub(result.StartTime)))
|
|
|
|
if len(result.Steps) > 0 {
|
|
fmt.Println()
|
|
fmt.Println(terminal.ResultSymbol() + " " + terminal.Bold("Step Results:"))
|
|
table := terminal.NewTable(os.Stdout, []string{"Status", "Step", "Duration"})
|
|
|
|
for _, step := range result.Steps {
|
|
table.Append([]string{
|
|
terminal.StepSymbol(string(step.Status)),
|
|
step.StepName,
|
|
formatDuration(step.Duration),
|
|
})
|
|
}
|
|
table.Render()
|
|
}
|
|
|
|
if len(result.Artifacts) > 0 {
|
|
fmt.Println()
|
|
fmt.Println(terminal.ListSymbol() + " " + terminal.Bold("Artifacts:"))
|
|
for _, artifact := range result.Artifacts {
|
|
printer.Bullet(artifact)
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
// formatResultForCI formats a workflow result for CI JSON output
|
|
func formatResultForCI(result *core.WorkflowResult) map[string]interface{} {
|
|
steps := make([]map[string]interface{}, 0, len(result.Steps))
|
|
for _, step := range result.Steps {
|
|
steps = append(steps, map[string]interface{}{
|
|
"name": step.StepName,
|
|
"status": string(step.Status),
|
|
"duration": formatDuration(step.Duration),
|
|
})
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"workflow": result.WorkflowName,
|
|
"run_id": result.RunID,
|
|
"target": result.Target,
|
|
"status": string(result.Status),
|
|
"duration": formatDuration(result.EndTime.Sub(result.StartTime)),
|
|
"start_time": result.StartTime.Format(time.RFC3339),
|
|
"end_time": result.EndTime.Format(time.RFC3339),
|
|
"steps": steps,
|
|
"artifacts": result.Artifacts,
|
|
}
|
|
}
|
|
|
|
// formatDuration formats a duration in human-readable format
|
|
func formatDuration(d time.Duration) string {
|
|
if d < time.Second {
|
|
return fmt.Sprintf("%dms", d.Milliseconds())
|
|
}
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
|
}
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
|
|
}
|
|
return fmt.Sprintf("%dh %dm %ds", int(d.Hours()), int(d.Minutes())%60, int(d.Seconds())%60)
|
|
}
|
|
|
|
// parseRunDuration parses duration strings like "30s", "2h", "1d"
|
|
// Extends time.ParseDuration to support days (d)
|
|
func parseRunDuration(s string) (time.Duration, error) {
|
|
if s == "" {
|
|
return 0, nil
|
|
}
|
|
|
|
// Handle days specially (not supported by time.ParseDuration)
|
|
if strings.HasSuffix(s, "d") {
|
|
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid duration: %s", s)
|
|
}
|
|
return time.Duration(days) * 24 * time.Hour, nil
|
|
}
|
|
|
|
return time.ParseDuration(s)
|
|
}
|
|
|
|
// generateEmptyTarget creates a placeholder target name for --empty-target mode
|
|
func generateEmptyTarget() string {
|
|
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
random := make([]byte, 6)
|
|
for i := range random {
|
|
random[i] = chars[rand.Intn(len(chars))]
|
|
}
|
|
return fmt.Sprintf("empty-%s-%d", string(random), time.Now().Unix())
|
|
}
|
|
|
|
// readWorkflowFromStdin reads and parses a workflow YAML from stdin
|
|
func readWorkflowFromStdin() (*core.Workflow, error) {
|
|
stat, err := os.Stdin.Stat()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to stat stdin: %w", err)
|
|
}
|
|
|
|
// Check if stdin has piped data
|
|
if (stat.Mode() & os.ModeCharDevice) != 0 {
|
|
return nil, fmt.Errorf("no data piped to stdin")
|
|
}
|
|
|
|
content, err := io.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read stdin: %w", err)
|
|
}
|
|
|
|
if len(content) == 0 {
|
|
return nil, fmt.Errorf("stdin is empty")
|
|
}
|
|
|
|
workflow, err := parser.ParseContent(content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse workflow: %w", err)
|
|
}
|
|
|
|
// Validate the workflow
|
|
p := parser.NewParser()
|
|
if err := p.Validate(workflow); err != nil {
|
|
return nil, fmt.Errorf("workflow validation failed: %w", err)
|
|
}
|
|
|
|
return workflow, nil
|
|
}
|
|
|
|
// executeSingleWorkflowDirect executes a pre-loaded workflow against all targets
|
|
// loader can be nil for module workflows loaded from stdin (flows not supported for stdin)
|
|
func executeSingleWorkflowDirect(ctx context.Context, workflow *core.Workflow, allTargets []string, cfg *config.Config, printer *terminal.Printer, log *zap.Logger, loader *parser.Loader) error {
|
|
// Show target count and concurrency
|
|
if len(allTargets) > 1 {
|
|
printer.Info("Running against %d targets (concurrency: %d)", len(allTargets), concurrency)
|
|
}
|
|
|
|
results, lastErr := executeRunsConcurrentlyWithContext(ctx, workflow, allTargets, cfg, concurrency, loader)
|
|
|
|
// Print summary for multiple targets
|
|
if len(allTargets) > 1 {
|
|
printMultiTargetSummary(results, len(allTargets))
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// runDistributedRun submits run tasks to the distributed worker queue
|
|
func runDistributedRun(cfg *config.Config, allTargets []string, printer *terminal.Printer) error {
|
|
// Override Redis config from URL if provided
|
|
if redisURLRun != "" {
|
|
redisCfg, err := distributed.ParseRedisURL(redisURLRun)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid redis URL: %w", err)
|
|
}
|
|
cfg.Redis = *redisCfg
|
|
}
|
|
|
|
// Check Redis is configured
|
|
if !cfg.IsRedisConfigured() {
|
|
return fmt.Errorf("redis not configured. Add redis section to osm-settings.yaml or use --redis-url")
|
|
}
|
|
|
|
// Determine workflow name and kind
|
|
workflowName := flowName
|
|
workflowKind := "flow"
|
|
if workflowName == "" && len(moduleNames) > 0 {
|
|
workflowName = moduleNames[0] // Use first module for distributed run
|
|
workflowKind = "module"
|
|
}
|
|
|
|
if workflowName == "" {
|
|
return fmt.Errorf("workflow name required (use -f or -m)")
|
|
}
|
|
|
|
// Create master client to submit tasks
|
|
master, err := distributed.NewMaster(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create master client: %w", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Parse additional params
|
|
params := make(map[string]interface{})
|
|
for _, flag := range paramFlags {
|
|
parts := strings.SplitN(flag, "=", 2)
|
|
if len(parts) == 2 {
|
|
params[parts[0]] = parts[1]
|
|
}
|
|
}
|
|
|
|
// Submit task for each target
|
|
printer.Section("Submitting Distributed Tasks")
|
|
|
|
var taskIDs []string
|
|
for _, target := range allTargets {
|
|
task := &distributed.Task{
|
|
WorkflowName: workflowName,
|
|
WorkflowKind: workflowKind,
|
|
Target: target,
|
|
Params: params,
|
|
}
|
|
|
|
if err := master.SubmitTask(ctx, task); err != nil {
|
|
printer.Error("Failed to submit task for %s: %s", target, err)
|
|
continue
|
|
}
|
|
|
|
taskIDs = append(taskIDs, task.ID)
|
|
printer.Success("Submitted task %s for target: %s", task.ID, target)
|
|
}
|
|
|
|
// Print summary
|
|
fmt.Println()
|
|
printer.Info("Submitted %d tasks to the distributed queue", len(taskIDs))
|
|
printer.Info("Use 'osmedeus worker status' to check worker availability")
|
|
printer.Info("Tasks will be processed by available workers")
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureExternalBinariesInPath adds the external-binaries folder to PATH if it exists
|
|
// and is not already present. This ensures installed tools are available even if
|
|
// the user hasn't reloaded their shell after running `osmedeus install binary`.
|
|
func ensureExternalBinariesInPath(cfg *config.Config) {
|
|
if cfg.BinariesPath == "" {
|
|
return
|
|
}
|
|
|
|
// Check if directory exists
|
|
if _, err := os.Stat(cfg.BinariesPath); os.IsNotExist(err) {
|
|
return
|
|
}
|
|
|
|
// Get current PATH
|
|
currentPath := os.Getenv("PATH")
|
|
|
|
// Check if already in PATH
|
|
pathSep := string(os.PathListSeparator)
|
|
paths := strings.Split(currentPath, pathSep)
|
|
for _, p := range paths {
|
|
if p == cfg.BinariesPath {
|
|
return // Already in PATH
|
|
}
|
|
}
|
|
|
|
// Prepend external-binaries to PATH
|
|
newPath := cfg.BinariesPath + pathSep + currentPath
|
|
_ = os.Setenv("PATH", newPath)
|
|
|
|
log := logger.Get()
|
|
log.Debug("Added external-binaries to PATH", zap.String("path", cfg.BinariesPath))
|
|
}
|