Files
osmedeus/internal/core/context.go
T
j3ssie b6e9d12324 feat: implement path-friendly loop variables and improve asset merge logic
- Add auto-generated _<variable>_ path-friendly variables for foreach loops that sanitize unsafe filesystem characters (/, :, etc.) and deterministically truncate long values
- Refactor asset and vulnerability import/merge logic to preserve existing non-empty fields instead of full overwrite on conflict
- Add mergeAssetFields() and mergeVulnFields() helper functions for consistent field-level merge behavior across all import methods
- Add comprehensive unit tests for merge functions and path-friendly variable behavior in foreach loops
- Add E2E test module (test-foreach-path-friendly) validating sanitization, directory creation, truncation, and variable coexistence
2026-02-13 13:27:04 +07:00

264 lines
7.3 KiB
Go

package core
import (
"fmt"
"hash/crc32"
"strings"
"sync"
"sync/atomic"
"go.uber.org/zap"
)
// ExecutionContext holds runtime state for workflow execution
type ExecutionContext struct {
WorkflowName string
WorkflowKind WorkflowKind
RunUUID string
Target string
WorkspacePath string
BaseFolder string
// Params are the input parameters (immutable after init)
Params map[string]interface{}
// Exports are variables exported by steps (mutable)
Exports map[string]interface{}
// Variables combines Params and Exports for template rendering
Variables map[string]interface{}
// Logger for this execution
Logger *zap.Logger
// StepIndex tracks the current step number (for display purposes)
StepIndex int
// WorkspaceName is the workspace identifier for database operations
WorkspaceName string
// mu protects concurrent access to Exports and Variables
mu sync.RWMutex
// variablesSnapshot provides O(1) read access for GetVariables()
// Updated atomically on SetVariable/SetExport/MergeExports/SetParam
variablesSnapshot atomic.Value // map[string]interface{}
}
// NewExecutionContext creates a new execution context
func NewExecutionContext(workflowName string, kind WorkflowKind, runUUID, target string) *ExecutionContext {
return &ExecutionContext{
WorkflowName: workflowName,
WorkflowKind: kind,
RunUUID: runUUID,
Target: target,
Params: make(map[string]interface{}),
Exports: make(map[string]interface{}),
Variables: make(map[string]interface{}),
}
}
// updateSnapshot creates an immutable copy of Variables for fast reads
// Must be called with c.mu held (Lock, not RLock)
func (c *ExecutionContext) updateSnapshot() {
snapshot := make(map[string]interface{}, len(c.Variables))
for k, v := range c.Variables {
snapshot[k] = v
}
c.variablesSnapshot.Store(snapshot)
}
// SetParam sets a parameter value
func (c *ExecutionContext) SetParam(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.Params[key] = value
c.Variables[key] = value
c.updateSnapshot()
}
// GetParam gets a parameter value
func (c *ExecutionContext) GetParam(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.Params[key]
return v, ok
}
// SetExport sets an exported variable
func (c *ExecutionContext) SetExport(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.Exports[key] = value
c.Variables[key] = value
c.updateSnapshot()
}
// GetExport gets an exported variable
func (c *ExecutionContext) GetExport(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.Exports[key]
return v, ok
}
// GetVariable gets a variable (param or export)
func (c *ExecutionContext) GetVariable(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.Variables[key]
return v, ok
}
// SetVariable sets a variable
func (c *ExecutionContext) SetVariable(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.Variables[key] = value
c.updateSnapshot()
}
// GetVariables returns all variables for template rendering.
// Uses atomic snapshot for O(1) read performance.
func (c *ExecutionContext) GetVariables() map[string]interface{} {
// Fast path: return cached snapshot (no lock needed)
if snapshot := c.variablesSnapshot.Load(); snapshot != nil {
return snapshot.(map[string]interface{})
}
// Fallback for uninitialized contexts (shouldn't happen in normal use)
c.mu.RLock()
defer c.mu.RUnlock()
vars := make(map[string]interface{}, len(c.Variables))
for k, v := range c.Variables {
vars[k] = v
}
return vars
}
// MergeExports merges exports from a step result
func (c *ExecutionContext) MergeExports(exports map[string]interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
for k, v := range exports {
c.Exports[k] = v
c.Variables[k] = v
}
c.updateSnapshot()
}
// Clone creates a shallow copy of the context for child execution
func (c *ExecutionContext) Clone() *ExecutionContext {
c.mu.RLock()
defer c.mu.RUnlock()
clone := &ExecutionContext{
WorkflowName: c.WorkflowName,
WorkflowKind: c.WorkflowKind,
RunUUID: c.RunUUID,
Target: c.Target,
WorkspacePath: c.WorkspacePath,
BaseFolder: c.BaseFolder,
WorkspaceName: c.WorkspaceName,
Params: make(map[string]interface{}, len(c.Params)),
Exports: make(map[string]interface{}, len(c.Exports)),
Variables: make(map[string]interface{}, len(c.Variables)),
Logger: c.Logger,
}
for k, v := range c.Params {
clone.Params[k] = v
}
for k, v := range c.Exports {
clone.Exports[k] = v
}
for k, v := range c.Variables {
clone.Variables[k] = v
}
// Initialize snapshot for fast GetVariables() reads
clone.updateSnapshot()
return clone
}
// CloneForLoop creates an optimized clone for foreach/parallel iterations.
// Key optimizations:
// - Shares Params reference (documented as immutable after init)
// - Pre-sets loop variables to avoid separate SetVariable calls
// - Reduces map copy overhead by ~33% (skips Params copy)
//
// Auto-set loop variables:
// - loopVar (e.g. "url"): the raw loop value
// - _id_: 1-based iteration number
// - _<loopVar>_ (e.g. "_url_"): path-friendly sanitized version of the loop value (string values only)
func (c *ExecutionContext) CloneForLoop(loopVar string, loopValue interface{}, iterID int) *ExecutionContext {
c.mu.RLock()
defer c.mu.RUnlock()
// Estimate capacity: parent variables + 3 loop variables (_id_, loopVar, _loopVar_)
varCapacity := len(c.Variables) + 3
clone := &ExecutionContext{
WorkflowName: c.WorkflowName,
WorkflowKind: c.WorkflowKind,
RunUUID: c.RunUUID,
Target: c.Target,
WorkspacePath: c.WorkspacePath,
BaseFolder: c.BaseFolder,
WorkspaceName: c.WorkspaceName,
Logger: c.Logger,
// Share immutable Params reference (no copy needed)
Params: c.Params,
// Fresh exports map for this iteration
Exports: make(map[string]interface{}, 4),
// Variables map with pre-allocated capacity
Variables: make(map[string]interface{}, varCapacity),
}
// Copy parent Variables for template rendering
for k, v := range c.Variables {
clone.Variables[k] = v
}
// Pre-set loop variables (avoids separate SetVariable calls)
if loopVar != "" {
clone.Variables[loopVar] = loopValue
// Auto-generate path-friendly version: _<loopVar>_ (e.g., _url_)
if strValue, ok := loopValue.(string); ok {
clone.Variables["_"+loopVar+"_"] = sanitizeToPathFriendlyDeterministic(strValue)
}
}
clone.Variables["_id_"] = iterID
// Initialize snapshot for fast GetVariables() reads
clone.updateSnapshot()
return clone
}
// sanitizeToPathFriendlyDeterministic converts a string to a filesystem-safe name.
// Replaces /\:*?"<>| with underscores. If the result exceeds 30 characters,
// it is truncated to prefix(20)_crc32hex(8) for determinism.
// CRC32 is computed on the original input so inputs differing only in unsafe chars
// produce different hashes.
func sanitizeToPathFriendlyDeterministic(s string) string {
hash := crc32.ChecksumIEEE([]byte(s))
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
b.WriteByte('_')
default:
b.WriteRune(r)
}
}
sanitized := b.String()
if len(sanitized) > 30 {
return fmt.Sprintf("%s_%08x", sanitized[:20], hash)
}
return sanitized
}