mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-09-22 09:34:57 +02:00
Fixes four reported issues. #321 / #322 — POST /osm/api/runs was unusable when the server was started with --settings-file. That branch in pkg/cli/root.go loaded the settings via config.LoadFromFile (a plain YAML unmarshal) and never called ResolvePaths(), so every derived runtime path stayed empty. An empty WorkflowsPath made workflow lookups scan "" (reported as "Workflow not found") and made a flow's relative module refs resolve against the process working directory. - root.go now resolves paths (and applies env overrides) on that branch - ResolvePaths backfills any environments.* key a partial settings file omits, sourced from the new defaultEnvironments() so DefaultConfig and the backfill can no longer drift - parser.ErrWorkflowsDirNotConfigured replaces the silent cwd fallback, so all loader call sites report the misconfiguration instead of a misleading 404 #323 — a run submitted with run_mode:distributed could be lost permanently. BRPOP is at-most-once: a task popped but not yet recorded in osm:tasks:running existed nowhere, and the master's recovery sweep only reads that hash. - workers now claim via BLMOVE onto osm:tasks:processing:{worker_id} - the claim is acked only once SetTaskRunning succeeds; a failure requeues instead of executing the task untracked - recovery on worker startup, on dead workers, and for processing lists left by workers that are no longer registered - requires Redis 6.2+ #320 — settings values can now be overridden by OSM_* environment variables so secrets need not live in osm-settings.yaml. The mapping is derived from the YAML tags by reflection, so new settings are overridable with no extra code. Applied in config.Load, hotreload and the --settings-file branch, but deliberately not in LoadFromFile: `osmedeus config set` round-trips through it and writes back, which would persist env secrets to disk. Known gaps: pkg/cli/worker_queue.go still consumes the pending queue with the at-most-once PopTask, and two distributed e2e tests (TaskSubmission, FullWorkflow) fail on main independently of these changes.
494 lines
13 KiB
Go
494 lines
13 KiB
Go
package parser
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/j3ssie/osmedeus/v5/internal/core"
|
|
"github.com/j3ssie/osmedeus/v5/internal/logger"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// CacheEntry holds a cached workflow with metadata for invalidation
|
|
type CacheEntry struct {
|
|
Workflow *core.Workflow
|
|
FilePath string // Absolute path for mtime check
|
|
ModTime time.Time // File modification time when cached
|
|
}
|
|
|
|
// ErrWorkflowsDirNotConfigured is returned when a Loader was built with an empty
|
|
// workflows directory. Every lookup would otherwise resolve against the process
|
|
// working directory and report a misleading "workflow not found", which is what
|
|
// made this misconfiguration so hard to diagnose from the API.
|
|
var ErrWorkflowsDirNotConfigured = errors.New("workflows directory is not configured")
|
|
|
|
// Loader loads and caches workflows
|
|
type Loader struct {
|
|
workflowsDir string
|
|
modulesDir string
|
|
parser *Parser
|
|
cache map[string]*CacheEntry
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewLoader creates a new workflow loader
|
|
func NewLoader(workflowsDir string) *Loader {
|
|
return &Loader{
|
|
workflowsDir: workflowsDir,
|
|
modulesDir: filepath.Join(workflowsDir, "modules"),
|
|
parser: NewParser(),
|
|
cache: make(map[string]*CacheEntry),
|
|
}
|
|
}
|
|
|
|
// isCacheValid checks if a cache entry is still valid by comparing file mtime
|
|
func (l *Loader) isCacheValid(entry *CacheEntry) bool {
|
|
if entry == nil || entry.FilePath == "" {
|
|
return false
|
|
}
|
|
info, err := os.Stat(entry.FilePath)
|
|
if err != nil {
|
|
return false // File gone or inaccessible
|
|
}
|
|
return !info.ModTime().After(entry.ModTime)
|
|
}
|
|
|
|
// LoadWorkflow loads a single workflow by name or path
|
|
// If name looks like a path (contains separator or ends with .yaml/.yml), it loads by path
|
|
// Otherwise, it searches for the workflow by name in the workflows directory
|
|
func (l *Loader) LoadWorkflow(name string) (*core.Workflow, error) {
|
|
log := logger.Get()
|
|
|
|
log.Debug("LoadWorkflow called",
|
|
zap.String("name", name),
|
|
zap.String("workflows_dir", l.workflowsDir),
|
|
)
|
|
|
|
// If name looks like a path (contains path separator or ends with .yaml/.yml)
|
|
if strings.Contains(name, string(filepath.Separator)) ||
|
|
strings.Contains(name, "/") ||
|
|
strings.HasSuffix(name, ".yaml") ||
|
|
strings.HasSuffix(name, ".yml") {
|
|
log.Debug("Loading workflow by path", zap.String("path", name))
|
|
return l.LoadWorkflowByPath(name)
|
|
}
|
|
|
|
// Check cache first with mtime validation
|
|
l.mu.RLock()
|
|
if entry, ok := l.cache[name]; ok {
|
|
if l.isCacheValid(entry) {
|
|
l.mu.RUnlock()
|
|
log.Debug("Workflow loaded from cache (mtime valid)", zap.String("name", name))
|
|
return entry.Workflow, nil
|
|
}
|
|
log.Debug("Cache entry invalid (file modified), will re-parse", zap.String("name", name))
|
|
}
|
|
l.mu.RUnlock()
|
|
|
|
log.Debug("Workflow not in cache, searching directory",
|
|
zap.String("name", name),
|
|
zap.String("dir", l.workflowsDir),
|
|
)
|
|
|
|
// Search recursively in the workflows directory
|
|
files, err := l.findYAMLFiles(l.workflowsDir, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan workflows directory: %w", err)
|
|
}
|
|
|
|
log.Debug("Found YAML files", zap.Int("count", len(files)))
|
|
|
|
// Look for exact match first (name.yaml or name.yml)
|
|
for _, file := range files {
|
|
baseName := filepath.Base(file)
|
|
nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
|
if nameWithoutExt == name {
|
|
log.Debug("Found exact match", zap.String("file", file))
|
|
return l.loadAndCache(name, file)
|
|
}
|
|
}
|
|
|
|
// Try with -flow or -module suffix
|
|
for _, file := range files {
|
|
baseName := filepath.Base(file)
|
|
nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
|
if nameWithoutExt == name+"-flow" || nameWithoutExt == name+"-module" {
|
|
log.Debug("Found with suffix", zap.String("file", file))
|
|
return l.loadAndCache(name, file)
|
|
}
|
|
}
|
|
|
|
log.Debug("Workflow not found", zap.String("name", name))
|
|
return nil, fmt.Errorf("workflow not found: %s", name)
|
|
}
|
|
|
|
// LoadWorkflowByPath loads a workflow from a specific path
|
|
func (l *Loader) LoadWorkflowByPath(path string) (*core.Workflow, error) {
|
|
log := logger.Get()
|
|
|
|
// Detect if this is an explicit local path (starts with ./ or ../)
|
|
isExplicitLocalPath := strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../")
|
|
|
|
// If path is absolute, use it directly
|
|
if !filepath.IsAbs(path) {
|
|
// Check if file exists relative to CWD first
|
|
if _, err := os.Stat(path); err == nil {
|
|
// File exists relative to CWD, make it absolute
|
|
absPath, err := filepath.Abs(path)
|
|
if err == nil {
|
|
path = absPath
|
|
}
|
|
} else if isExplicitLocalPath {
|
|
// For explicit local paths (./foo.yaml or ../foo.yaml),
|
|
// don't fall back to workflowsDir - return a clear error
|
|
absPath, _ := filepath.Abs(path)
|
|
return nil, fmt.Errorf("workflow file not found: %s", absPath)
|
|
} else {
|
|
// File doesn't exist relative to CWD, try relative to workflowsDir
|
|
if l.workflowsDir == "" {
|
|
return nil, ErrWorkflowsDirNotConfigured
|
|
}
|
|
log.Debug("Workflow not found at relative path, trying workflows directory",
|
|
zap.String("original_path", path),
|
|
zap.String("workflows_dir", l.workflowsDir),
|
|
)
|
|
path = filepath.Join(l.workflowsDir, path)
|
|
}
|
|
}
|
|
|
|
// Get name from path for caching (handle both .yaml and .yml)
|
|
name := filepath.Base(path)
|
|
name = strings.TrimSuffix(name, ".yaml")
|
|
name = strings.TrimSuffix(name, ".yml")
|
|
|
|
return l.loadAndCache(name, path)
|
|
}
|
|
|
|
// loadAndCache loads a workflow and caches it
|
|
func (l *Loader) loadAndCache(name, path string) (*core.Workflow, error) {
|
|
log := logger.Get()
|
|
|
|
log.Debug("Parsing workflow file",
|
|
zap.String("name", name),
|
|
zap.String("path", path),
|
|
)
|
|
|
|
workflow, err := l.parser.Parse(path)
|
|
if err != nil {
|
|
log.Debug("Failed to parse workflow", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
log.Debug("Workflow parsed",
|
|
zap.String("name", workflow.Name),
|
|
zap.String("kind", string(workflow.Kind)),
|
|
zap.Int("steps", len(workflow.Steps)),
|
|
)
|
|
|
|
// Resolve inheritance if workflow extends another
|
|
if workflow.Extends != "" {
|
|
log.Debug("Resolving workflow inheritance",
|
|
zap.String("workflow", workflow.Name),
|
|
zap.String("extends", workflow.Extends),
|
|
)
|
|
|
|
resolver := NewInheritanceResolver(l)
|
|
workflow, err = resolver.Resolve(workflow)
|
|
if err != nil {
|
|
log.Debug("Inheritance resolution failed", zap.Error(err))
|
|
return nil, fmt.Errorf("inheritance resolution: %w", err)
|
|
}
|
|
|
|
log.Debug("Inheritance resolved",
|
|
zap.String("workflow", workflow.Name),
|
|
zap.String("resolved_from", workflow.ResolvedFrom),
|
|
)
|
|
}
|
|
|
|
// Validate workflow (after inheritance resolution)
|
|
if err := l.parser.Validate(workflow); err != nil {
|
|
log.Debug("Workflow validation failed", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
log.Debug("Workflow validated, caching",
|
|
zap.String("cache_key", name),
|
|
)
|
|
|
|
// Get file modification time for cache invalidation
|
|
absPath, _ := filepath.Abs(path)
|
|
var modTime time.Time
|
|
if info, err := os.Stat(absPath); err == nil {
|
|
modTime = info.ModTime()
|
|
}
|
|
|
|
// Cache the workflow with metadata
|
|
entry := &CacheEntry{
|
|
Workflow: workflow,
|
|
FilePath: absPath,
|
|
ModTime: modTime,
|
|
}
|
|
l.mu.Lock()
|
|
l.cache[name] = entry
|
|
l.mu.Unlock()
|
|
|
|
return workflow, nil
|
|
}
|
|
|
|
// LoadAllWorkflows loads all workflows from the configured directories recursively
|
|
func (l *Loader) LoadAllWorkflows() ([]*core.Workflow, error) {
|
|
var workflows []*core.Workflow
|
|
|
|
// Recursively find all YAML files
|
|
files, err := l.findYAMLFiles(l.workflowsDir, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan workflows directory: %w", err)
|
|
}
|
|
|
|
for _, file := range files {
|
|
w, err := l.LoadWorkflowByPath(file)
|
|
if err != nil {
|
|
// Log warning but continue
|
|
continue
|
|
}
|
|
workflows = append(workflows, w)
|
|
}
|
|
|
|
return workflows, nil
|
|
}
|
|
|
|
// LoadFlowWithModules loads a flow workflow and pre-loads all its referenced modules in parallel.
|
|
// This improves startup time for complex flows with many module references.
|
|
func (l *Loader) LoadFlowWithModules(name string) (*core.Workflow, map[string]*core.Workflow, error) {
|
|
log := logger.Get()
|
|
|
|
// First load the flow itself
|
|
flow, err := l.LoadWorkflow(name)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to load flow: %w", err)
|
|
}
|
|
|
|
// If not a flow or no modules, return early
|
|
if flow.Kind != core.KindFlow || len(flow.Modules) == 0 {
|
|
return flow, nil, nil
|
|
}
|
|
|
|
log.Debug("Loading flow modules in parallel",
|
|
zap.String("flow", name),
|
|
zap.Int("module_count", len(flow.Modules)),
|
|
)
|
|
|
|
// Collect unique module names (skip inline modules)
|
|
moduleNames := make(map[string]struct{})
|
|
for _, ref := range flow.Modules {
|
|
if !ref.IsInline() && ref.Name != "" {
|
|
moduleNames[ref.Name] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if len(moduleNames) == 0 {
|
|
return flow, nil, nil
|
|
}
|
|
|
|
// Load modules in parallel using errgroup
|
|
g, ctx := errgroup.WithContext(context.Background())
|
|
modules := make(map[string]*core.Workflow)
|
|
var mu sync.Mutex
|
|
|
|
for modName := range moduleNames {
|
|
modName := modName // capture for goroutine
|
|
g.Go(func() error {
|
|
// Check context cancellation
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
mod, err := l.LoadWorkflow(modName)
|
|
if err != nil {
|
|
log.Warn("Failed to load module",
|
|
zap.String("module", modName),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("failed to load module %s: %w", modName, err)
|
|
}
|
|
|
|
mu.Lock()
|
|
modules[modName] = mod
|
|
mu.Unlock()
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := g.Wait(); err != nil {
|
|
return flow, modules, err
|
|
}
|
|
|
|
log.Debug("Loaded all flow modules",
|
|
zap.String("flow", name),
|
|
zap.Int("loaded", len(modules)),
|
|
)
|
|
|
|
return flow, modules, nil
|
|
}
|
|
|
|
// shouldSkipPath returns true for paths that are obviously not workflow files
|
|
func shouldSkipPath(path string) bool {
|
|
// Skip hidden directories (except the workflow root itself)
|
|
parts := strings.Split(path, string(os.PathSeparator))
|
|
for _, part := range parts {
|
|
if strings.HasPrefix(part, ".") && part != "." {
|
|
return true // Skip .github/, .gitlab/, .git/, etc.
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isWorkflowYAML checks if a YAML file contains kind: module or kind: flow
|
|
func isWorkflowYAML(path string) bool {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if !strings.Contains(string(content), "kind:") {
|
|
return false
|
|
}
|
|
kindPattern := regexp.MustCompile(`(?m)^kind:\s*['"]?(module|flow)['"]?\s*$`)
|
|
return kindPattern.Match(content)
|
|
}
|
|
|
|
// findYAMLFiles finds all YAML files in a directory
|
|
func (l *Loader) findYAMLFiles(dir string, recursive bool) ([]string, error) {
|
|
var files []string
|
|
|
|
if dir == "" {
|
|
return nil, ErrWorkflowsDirNotConfigured
|
|
}
|
|
|
|
if recursive {
|
|
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() && (strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml")) {
|
|
if shouldSkipPath(path) {
|
|
return nil // Skip hidden directories
|
|
}
|
|
if !isWorkflowYAML(path) {
|
|
return nil // Skip non-workflow YAML files
|
|
}
|
|
files = append(files, path)
|
|
}
|
|
return nil
|
|
})
|
|
return files, err
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".yaml") || strings.HasSuffix(entry.Name(), ".yml")) {
|
|
path := filepath.Join(dir, entry.Name())
|
|
if shouldSkipPath(path) {
|
|
continue // Skip hidden directories
|
|
}
|
|
if !isWorkflowYAML(path) {
|
|
continue // Skip non-workflow YAML files
|
|
}
|
|
files = append(files, path)
|
|
}
|
|
}
|
|
|
|
return files, nil
|
|
}
|
|
|
|
// ReloadWorkflows clears cache and reloads all workflows
|
|
func (l *Loader) ReloadWorkflows() error {
|
|
l.mu.Lock()
|
|
l.cache = make(map[string]*CacheEntry)
|
|
l.mu.Unlock()
|
|
|
|
_, err := l.LoadAllWorkflows()
|
|
return err
|
|
}
|
|
|
|
// GetWorkflow returns a cached workflow by name
|
|
func (l *Loader) GetWorkflow(name string) (*core.Workflow, bool) {
|
|
l.mu.RLock()
|
|
defer l.mu.RUnlock()
|
|
entry, ok := l.cache[name]
|
|
if !ok || entry == nil {
|
|
return nil, false
|
|
}
|
|
return entry.Workflow, true
|
|
}
|
|
|
|
// GetAllCached returns all cached workflows
|
|
func (l *Loader) GetAllCached() []*core.Workflow {
|
|
l.mu.RLock()
|
|
defer l.mu.RUnlock()
|
|
|
|
workflows := make([]*core.Workflow, 0, len(l.cache))
|
|
for _, entry := range l.cache {
|
|
if entry != nil && entry.Workflow != nil {
|
|
workflows = append(workflows, entry.Workflow)
|
|
}
|
|
}
|
|
return workflows
|
|
}
|
|
|
|
// ListAllWorkflows recursively scans the workflow directory and returns
|
|
// all workflows categorized by their kind (flow or module)
|
|
func (l *Loader) ListAllWorkflows() (flows, modules []string, err error) {
|
|
files, err := l.findYAMLFiles(l.workflowsDir, true) // recursive=true
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
for _, file := range files {
|
|
wf, err := l.parser.Parse(file)
|
|
if err != nil {
|
|
continue // skip invalid files
|
|
}
|
|
name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
|
|
switch wf.Kind {
|
|
case core.KindFlow:
|
|
flows = append(flows, name)
|
|
default:
|
|
modules = append(modules, name)
|
|
}
|
|
}
|
|
return flows, modules, nil
|
|
}
|
|
|
|
// ListFlows returns names of all available flows
|
|
func (l *Loader) ListFlows() ([]string, error) {
|
|
flows, _, err := l.ListAllWorkflows()
|
|
return flows, err
|
|
}
|
|
|
|
// ListModules returns names of all available modules
|
|
func (l *Loader) ListModules() ([]string, error) {
|
|
_, modules, err := l.ListAllWorkflows()
|
|
return modules, err
|
|
}
|
|
|
|
// ClearCache clears the workflow cache
|
|
func (l *Loader) ClearCache() {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.cache = make(map[string]*CacheEntry)
|
|
}
|