Files
osmedeus/internal/parser/inheritance.go
T
j3ssie 4ac041fd73 feat: add workflow help metadata, artifact optional flag, and search/filter improvements
- Add WorkflowHelp struct with Usage and ExampleTargets for CLI documentation
- Add Optional field to Artifact model and database schema with migration support
- Implement workflow search functionality by name, description, and tags in CLI list command
- Add --usage and --search flags to workflow list command with multiple filtering options
- Display workflow usage info in show command when Help is defined
- Support help inheritance in workflow extends/inheritance resolver
- Update vulnerability counters from database after SARIF imports
- Add comprehensive Help unit tests covering parsing, cloning, and mutation isolation
- Improve test helpers with streaming output, diagnostics, and file validation utilities
- Add fourth general canary test for domain-list-recon flow with artifact validation
2026-02-12 01:04:03 +07:00

694 lines
19 KiB
Go

package parser
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/j3ssie/osmedeus/v5/internal/core"
"github.com/j3ssie/osmedeus/v5/internal/logger"
"go.uber.org/zap"
)
// InheritanceResolver resolves workflow inheritance chains
type InheritanceResolver struct {
loader *Loader
// Track workflows being resolved to detect circular dependencies
resolving map[string]bool
// childPath stores the directory of the current child being resolved
// for relative parent resolution
childPath string
}
// NewInheritanceResolver creates a new inheritance resolver
func NewInheritanceResolver(loader *Loader) *InheritanceResolver {
return &InheritanceResolver{
loader: loader,
resolving: make(map[string]bool),
}
}
// Resolve resolves the inheritance chain for a workflow
// Returns a new workflow with all inherited fields merged
func (r *InheritanceResolver) Resolve(child *core.Workflow) (*core.Workflow, error) {
log := logger.Get()
if child.Extends == "" {
return child, nil
}
log.Debug("Resolving inheritance",
zap.String("child", child.Name),
zap.String("extends", child.Extends),
)
// Check for circular dependency
if r.resolving[child.Name] {
return nil, fmt.Errorf("circular inheritance detected: %s", child.Name)
}
r.resolving[child.Name] = true
defer func() { delete(r.resolving, child.Name) }()
// Store child's directory for relative resolution
if child.FilePath != "" && r.childPath == "" {
r.childPath = filepath.Dir(child.FilePath)
}
// Load parent workflow
parent, err := r.loadParent(child.Extends)
if err != nil {
return nil, fmt.Errorf("failed to load parent workflow '%s': %w", child.Extends, err)
}
// Recursively resolve parent if it also extends another workflow
if parent.Extends != "" {
parent, err = r.Resolve(parent)
if err != nil {
return nil, fmt.Errorf("failed to resolve parent '%s': %w", child.Extends, err)
}
}
// Validate kind compatibility
if parent.Kind != child.Kind {
return nil, fmt.Errorf("kind mismatch: child '%s' is %s but parent '%s' is %s",
child.Name, child.Kind, parent.Name, parent.Kind)
}
// Merge parent into child
merged, err := r.merge(parent, child)
if err != nil {
return nil, fmt.Errorf("failed to merge workflows: %w", err)
}
// Track inheritance
merged.ResolvedFrom = child.Extends
log.Debug("Inheritance resolved",
zap.String("workflow", merged.Name),
zap.String("parent", child.Extends),
)
return merged, nil
}
// loadParent attempts to load a parent workflow by name or path
// It loads the workflow without triggering inheritance resolution (to avoid infinite loops)
// and then manually resolves inheritance using the same resolver
func (r *InheritanceResolver) loadParent(name string) (*core.Workflow, error) {
// Parse the parent workflow without automatic inheritance resolution
var parentPath string
// First, try to find the workflow file
// Try with .yaml extension in the same directory
if r.childPath != "" {
yamlPath := filepath.Join(r.childPath, name+".yaml")
if _, err := os.Stat(yamlPath); err == nil {
parentPath = yamlPath
} else {
// Try with .yml extension
ymlPath := filepath.Join(r.childPath, name+".yml")
if _, err := os.Stat(ymlPath); err == nil {
parentPath = ymlPath
}
}
}
// If not found in same directory, try the loader's workflows directory
if parentPath == "" {
// Try in the loader's workflows directory
if strings.Contains(name, "/") || strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") {
// Name looks like a path
if filepath.IsAbs(name) {
parentPath = name
} else if r.childPath != "" {
parentPath = filepath.Join(r.childPath, name)
}
}
}
var parent *core.Workflow
var err error
if parentPath != "" {
// Load without inheritance resolution by parsing directly
parent, err = r.loader.parser.Parse(parentPath)
if err != nil {
return nil, err
}
// Validate (but skip the module/flow validation since inheritance might fix it)
if parent.Kind != core.KindModule && parent.Kind != core.KindFlow {
return nil, fmt.Errorf("invalid kind in parent: %s", parent.Kind)
}
} else {
// Try to find using the loader's standard search
// But we need to parse without inheritance resolution
// Use the loader's internal search logic
parent, err = r.findAndParseParent(name)
if err != nil {
return nil, err
}
}
// If parent also extends, resolve that first using the same resolver
if parent.Extends != "" {
parent, err = r.Resolve(parent)
if err != nil {
return nil, err
}
}
return parent, nil
}
// findAndParseParent finds and parses a parent workflow by name without triggering
// automatic inheritance resolution
func (r *InheritanceResolver) findAndParseParent(name string) (*core.Workflow, error) {
// Try direct path first
if strings.Contains(name, "/") || strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") {
return r.loader.parser.Parse(name)
}
// Search in loader's workflow directory
workflowsDir := r.loader.workflowsDir
files, err := r.findYAMLFiles(workflowsDir)
if err != nil {
return nil, fmt.Errorf("failed to scan workflows directory: %w", err)
}
// Look for exact match
for _, file := range files {
baseName := filepath.Base(file)
nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
if nameWithoutExt == name || nameWithoutExt == name+"-flow" || nameWithoutExt == name+"-module" {
return r.loader.parser.Parse(file)
}
}
return nil, fmt.Errorf("workflow not found: %s", name)
}
// findYAMLFiles recursively finds all YAML files in a directory
func (r *InheritanceResolver) findYAMLFiles(dir string) ([]string, error) {
var files []string
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")) {
files = append(files, path)
}
return nil
})
return files, err
}
// merge creates a new workflow by merging parent and child
// Priority: Child direct fields > Child override > Parent
func (r *InheritanceResolver) merge(parent, child *core.Workflow) (*core.Workflow, error) {
// Start with a clone of the parent
merged := parent.Clone()
// Apply child's direct fields
merged.Name = child.Name
merged.FilePath = child.FilePath
merged.Checksum = child.Checksum
merged.Extends = "" // Clear extends to prevent re-resolution
// Apply child's description if set
if child.Description != "" {
merged.Description = child.Description
}
// Apply child's tags if set
if len(child.Tags) > 0 {
merged.Tags = make(core.TagList, len(child.Tags))
copy(merged.Tags, child.Tags)
}
// Apply child's help if set
if child.Help != nil {
merged.Help = child.Help.Clone()
}
// Apply overrides if present
if child.Override != nil {
if err := r.applyOverrides(merged, child.Override); err != nil {
return nil, err
}
}
return merged, nil
}
// applyOverrides applies the override section to the merged workflow
func (r *InheritanceResolver) applyOverrides(merged *core.Workflow, override *core.WorkflowOverride) error {
// Apply param overrides
if len(override.Params) > 0 {
r.mergeParams(merged, override.Params)
}
// Apply steps override (for modules)
if override.Steps != nil && merged.IsModule() {
if err := r.mergeSteps(merged, override.Steps); err != nil {
return err
}
}
// Apply modules override (for flows)
if override.Modules != nil && merged.IsFlow() {
if err := r.mergeModules(merged, override.Modules); err != nil {
return err
}
}
// Apply triggers override (replace entirely if set)
if len(override.Triggers) > 0 {
merged.Triggers = make([]core.Trigger, len(override.Triggers))
for i, t := range override.Triggers {
merged.Triggers[i] = *t.Clone()
}
}
// Apply dependencies override (merge)
if override.Dependencies != nil {
r.mergeDependencies(merged, override.Dependencies)
}
// Apply preferences override (child overrides parent)
if override.Preferences != nil {
r.mergePreferences(merged, override.Preferences)
}
// Apply runner config override (child overrides parent)
if override.RunnerConfig != nil {
r.mergeRunnerConfig(merged, override.RunnerConfig)
}
// Apply runner type override
if override.Runner != nil {
merged.Runner = *override.Runner
}
return nil
}
// mergeParams merges parameter overrides into the workflow
func (r *InheritanceResolver) mergeParams(merged *core.Workflow, overrides map[string]*core.ParamOverride) {
// Create a map for quick lookup
paramMap := make(map[string]int)
for i, p := range merged.Params {
paramMap[p.Name] = i
}
// Apply overrides
for name, override := range overrides {
if idx, exists := paramMap[name]; exists {
// Override existing param
param := &merged.Params[idx]
if override.Default != nil {
param.Default = override.Default
}
if override.Type != nil {
param.Type = *override.Type
}
if override.Required != nil {
param.Required = *override.Required
}
if override.Generator != nil {
param.Generator = *override.Generator
}
} else {
// Add new param with override values
newParam := core.Param{
Name: name,
}
if override.Default != nil {
newParam.Default = override.Default
}
if override.Type != nil {
newParam.Type = *override.Type
}
if override.Required != nil {
newParam.Required = *override.Required
}
if override.Generator != nil {
newParam.Generator = *override.Generator
}
merged.Params = append(merged.Params, newParam)
}
}
}
// mergeSteps merges step overrides into the workflow based on mode
func (r *InheritanceResolver) mergeSteps(merged *core.Workflow, override *core.StepsOverride) error {
mode := override.GetEffectiveMode()
switch mode {
case core.OverrideModeReplace:
// Completely replace parent steps
merged.Steps = make([]core.Step, len(override.Steps))
for i, s := range override.Steps {
merged.Steps[i] = *s.Clone()
}
case core.OverrideModePrepend:
// Add child steps before parent steps
newSteps := make([]core.Step, 0, len(override.Steps)+len(merged.Steps))
for _, s := range override.Steps {
newSteps = append(newSteps, *s.Clone())
}
newSteps = append(newSteps, merged.Steps...)
merged.Steps = newSteps
case core.OverrideModeAppend:
// Add child steps after parent steps
for _, s := range override.Steps {
merged.Steps = append(merged.Steps, *s.Clone())
}
case core.OverrideModeMerge:
// Match by name: replace matching, append new, remove specified
if err := r.mergeStepsByName(merged, override); err != nil {
return err
}
default:
return fmt.Errorf("invalid steps override mode: %s", mode)
}
return nil
}
// mergeStepsByName implements the merge mode for steps
func (r *InheritanceResolver) mergeStepsByName(merged *core.Workflow, override *core.StepsOverride) error {
// Create map of step names to indices
stepMap := make(map[string]int)
for i, s := range merged.Steps {
stepMap[s.Name] = i
}
// Create set of steps to remove
removeSet := make(map[string]bool)
for _, name := range override.Remove {
removeSet[name] = true
}
// Apply replacements
for _, replacement := range override.Replace {
if idx, exists := stepMap[replacement.Name]; exists {
merged.Steps[idx] = *replacement.Clone()
}
}
// Remove steps marked for removal
if len(removeSet) > 0 {
newSteps := make([]core.Step, 0, len(merged.Steps))
for _, s := range merged.Steps {
if !removeSet[s.Name] {
newSteps = append(newSteps, s)
}
}
merged.Steps = newSteps
// Rebuild step map after removal
stepMap = make(map[string]int)
for i, s := range merged.Steps {
stepMap[s.Name] = i
}
}
// Append new steps (those not already in the workflow)
for _, s := range override.Steps {
if _, exists := stepMap[s.Name]; !exists {
merged.Steps = append(merged.Steps, *s.Clone())
}
}
return nil
}
// mergeModules merges module overrides into the workflow based on mode
func (r *InheritanceResolver) mergeModules(merged *core.Workflow, override *core.ModulesOverride) error {
mode := override.GetEffectiveMode()
switch mode {
case core.OverrideModeReplace:
// Completely replace parent modules
merged.Modules = make([]core.ModuleRef, len(override.Modules))
for i, m := range override.Modules {
merged.Modules[i] = *m.Clone()
}
case core.OverrideModePrepend:
// Add child modules before parent modules
newModules := make([]core.ModuleRef, 0, len(override.Modules)+len(merged.Modules))
for _, m := range override.Modules {
newModules = append(newModules, *m.Clone())
}
newModules = append(newModules, merged.Modules...)
merged.Modules = newModules
case core.OverrideModeAppend:
// Add child modules after parent modules
for _, m := range override.Modules {
merged.Modules = append(merged.Modules, *m.Clone())
}
case core.OverrideModeMerge:
// Match by name: replace matching, append new, remove specified
if err := r.mergeModulesByName(merged, override); err != nil {
return err
}
default:
return fmt.Errorf("invalid modules override mode: %s", mode)
}
return nil
}
// mergeModulesByName implements the merge mode for modules
func (r *InheritanceResolver) mergeModulesByName(merged *core.Workflow, override *core.ModulesOverride) error {
// Create map of module names to indices
moduleMap := make(map[string]int)
for i, m := range merged.Modules {
moduleMap[m.Name] = i
}
// Create set of modules to remove
removeSet := make(map[string]bool)
for _, name := range override.Remove {
removeSet[name] = true
}
// Apply replacements
for _, replacement := range override.Replace {
if idx, exists := moduleMap[replacement.Name]; exists {
merged.Modules[idx] = *replacement.Clone()
}
}
// Remove modules marked for removal
if len(removeSet) > 0 {
newModules := make([]core.ModuleRef, 0, len(merged.Modules))
for _, m := range merged.Modules {
if !removeSet[m.Name] {
newModules = append(newModules, m)
}
}
merged.Modules = newModules
// Rebuild module map after removal
moduleMap = make(map[string]int)
for i, m := range merged.Modules {
moduleMap[m.Name] = i
}
}
// Append new modules (those not already in the workflow)
for _, m := range override.Modules {
if _, exists := moduleMap[m.Name]; !exists {
merged.Modules = append(merged.Modules, *m.Clone())
}
}
return nil
}
// mergeDependencies merges dependency overrides (union of all items)
func (r *InheritanceResolver) mergeDependencies(merged *core.Workflow, override *core.Dependencies) {
if merged.Dependencies == nil {
merged.Dependencies = &core.Dependencies{}
}
// Merge commands (deduplicated)
if len(override.Commands) > 0 {
cmdSet := make(map[string]bool)
for _, c := range merged.Dependencies.Commands {
cmdSet[c] = true
}
for _, c := range override.Commands {
if !cmdSet[c] {
merged.Dependencies.Commands = append(merged.Dependencies.Commands, c)
cmdSet[c] = true
}
}
}
// Merge files (deduplicated)
if len(override.Files) > 0 {
fileSet := make(map[string]bool)
for _, f := range merged.Dependencies.Files {
fileSet[f] = true
}
for _, f := range override.Files {
if !fileSet[f] {
merged.Dependencies.Files = append(merged.Dependencies.Files, f)
fileSet[f] = true
}
}
}
// Merge variables (deduplicated by name)
if len(override.Variables) > 0 {
varMap := make(map[string]int)
for i, v := range merged.Dependencies.Variables {
varMap[v.Name] = i
}
for _, v := range override.Variables {
if idx, exists := varMap[v.Name]; exists {
// Override existing variable
merged.Dependencies.Variables[idx] = v
} else {
// Add new variable
merged.Dependencies.Variables = append(merged.Dependencies.Variables, v)
varMap[v.Name] = len(merged.Dependencies.Variables) - 1
}
}
}
// Merge target types (deduplicated)
if len(override.TargetTypes) > 0 {
typeSet := make(map[core.TargetType]bool)
for _, t := range merged.Dependencies.TargetTypes {
typeSet[t] = true
}
for _, t := range override.TargetTypes {
if !typeSet[t] {
merged.Dependencies.TargetTypes = append(merged.Dependencies.TargetTypes, t)
typeSet[t] = true
}
}
}
// Merge function conditions (deduplicated)
if len(override.FunctionsConditions) > 0 {
condSet := make(map[string]bool)
for _, c := range merged.Dependencies.FunctionsConditions {
condSet[c] = true
}
for _, c := range override.FunctionsConditions {
if !condSet[c] {
merged.Dependencies.FunctionsConditions = append(merged.Dependencies.FunctionsConditions, c)
condSet[c] = true
}
}
}
}
// mergePreferences merges preference overrides (child overrides parent)
func (r *InheritanceResolver) mergePreferences(merged *core.Workflow, override *core.Preferences) {
if merged.Preferences == nil {
merged.Preferences = &core.Preferences{}
}
if override.DisableNotifications != nil {
merged.Preferences.DisableNotifications = override.DisableNotifications
}
if override.DisableLogging != nil {
merged.Preferences.DisableLogging = override.DisableLogging
}
if override.HeuristicsCheck != nil {
merged.Preferences.HeuristicsCheck = override.HeuristicsCheck
}
if override.CIOutputFormat != nil {
merged.Preferences.CIOutputFormat = override.CIOutputFormat
}
if override.Silent != nil {
merged.Preferences.Silent = override.Silent
}
if override.Repeat != nil {
merged.Preferences.Repeat = override.Repeat
}
if override.RepeatWaitTime != nil {
merged.Preferences.RepeatWaitTime = override.RepeatWaitTime
}
if override.EmptyTarget != nil {
merged.Preferences.EmptyTarget = override.EmptyTarget
}
}
// mergeRunnerConfig merges runner config overrides (child overrides parent)
func (r *InheritanceResolver) mergeRunnerConfig(merged *core.Workflow, override *core.RunnerConfig) {
if merged.RunnerConfig == nil {
merged.RunnerConfig = &core.RunnerConfig{}
}
// Override non-empty fields
if override.Image != "" {
merged.RunnerConfig.Image = override.Image
}
if override.Network != "" {
merged.RunnerConfig.Network = override.Network
}
if override.Persistent {
merged.RunnerConfig.Persistent = override.Persistent
}
if override.Host != "" {
merged.RunnerConfig.Host = override.Host
}
if override.Port != 0 {
merged.RunnerConfig.Port = override.Port
}
if override.User != "" {
merged.RunnerConfig.User = override.User
}
if override.KeyFile != "" {
merged.RunnerConfig.KeyFile = override.KeyFile
}
if override.Password != "" {
merged.RunnerConfig.Password = override.Password
}
if override.WorkDir != "" {
merged.RunnerConfig.WorkDir = override.WorkDir
}
// Merge env (child overrides parent for same keys)
if len(override.Env) > 0 {
if merged.RunnerConfig.Env == nil {
merged.RunnerConfig.Env = make(map[string]string)
}
for k, v := range override.Env {
merged.RunnerConfig.Env[k] = v
}
}
// Merge volumes (append, deduplicated)
if len(override.Volumes) > 0 {
volSet := make(map[string]bool)
for _, v := range merged.RunnerConfig.Volumes {
volSet[v] = true
}
for _, v := range override.Volumes {
if !volSet[v] {
merged.RunnerConfig.Volumes = append(merged.RunnerConfig.Volumes, v)
volSet[v] = true
}
}
}
}