Files

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(&paramFlags, "params", "p", nil, "additional parameters (key=value format)")
runCmd.Flags().StringVarP(&paramsFile, "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))
}