Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions playground/cmd_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package playground
import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/flashbots/builder-playground/utils"
Expand Down Expand Up @@ -79,12 +80,23 @@ func ValidateRecipe(recipe Recipe, baseRecipes []Recipe) *ValidationResult {
}

func validateYAMLRecipe(recipe *YAMLRecipe, baseRecipes []Recipe, result *ValidationResult) {
// Check base recipe exists
// Check base recipe exists (built-in name or file path)
baseFound := false
for _, r := range baseRecipes {
if r.Name() == recipe.config.Base {
if isYAMLBasePath(recipe.config.Base) {
// File-based base: check the referenced file exists
basePath := recipe.config.Base
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(recipe.filePath), basePath)
}
if _, err := os.Stat(basePath); err == nil {
baseFound = true
break
}
} else {
for _, r := range baseRecipes {
if r.Name() == recipe.config.Base {
baseFound = true
break
}
}
}
if !baseFound {
Expand Down
63 changes: 56 additions & 7 deletions playground/recipe_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,36 @@ type YAMLRecipe struct {

var _ Recipe = &YAMLRecipe{}

// ParseYAMLRecipe parses a YAML recipe file and returns a YAMLRecipe
// ParseYAMLRecipe parses a YAML recipe file and returns a YAMLRecipe.
// The base field can be a built-in recipe name (l1, opstack, buildernet) or
// a path to another YAML recipe file (e.g. ./signal-boost-recipe.yaml).
func ParseYAMLRecipe(filePath string, baseRecipes []Recipe) (*YAMLRecipe, error) {
return parseYAMLRecipe(filePath, baseRecipes, nil)
}

const maxBaseRecipeDepth = 10

func parseYAMLRecipe(filePath string, baseRecipes []Recipe, visited map[string]bool) (*YAMLRecipe, error) {
// Cycle detection for file-based base references
absPath, err := filepath.EvalSymlinks(filePath)
if err != nil {
// EvalSymlinks fails for non-existent files; fall back to Abs
absPath, err = filepath.Abs(filePath)
}
if err != nil {
return nil, fmt.Errorf("failed to resolve path %s: %w", filePath, err)
}
if visited == nil {
visited = make(map[string]bool)
}
if len(visited) >= maxBaseRecipeDepth {
return nil, fmt.Errorf("base recipe chain too deep (max %d)", maxBaseRecipeDepth)
}
if visited[absPath] {
return nil, fmt.Errorf("circular base recipe reference: %s", filePath)
}
visited[absPath] = true

data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read YAML recipe file: %w", err)
Expand All @@ -154,15 +182,29 @@ func ParseYAMLRecipe(filePath string, baseRecipes []Recipe) (*YAMLRecipe, error)
}

if config.Base == "" {
return nil, fmt.Errorf("YAML recipe must specify a 'base' recipe (l1, opstack, or buildernet)")
return nil, fmt.Errorf("YAML recipe must specify a 'base' recipe (e.g. l1, opstack, buildernet, or a path to another YAML recipe)")
}

// Find the base recipe
// Resolve the base recipe: either a built-in name or a YAML file path
var baseRecipe Recipe
for _, r := range baseRecipes {
if r.Name() == config.Base {
baseRecipe = r
break

if isYAMLBasePath(config.Base) {
// File-based base: resolve relative to the current recipe's directory
basePath := config.Base
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(filePath), basePath)
}
baseRecipe, err = parseYAMLRecipe(basePath, baseRecipes, visited)
Comment thread
fkondej marked this conversation as resolved.
if err != nil {
return nil, fmt.Errorf("failed to parse base recipe %s: %w", config.Base, err)
}
} else {
// Built-in base: find by name
for _, r := range baseRecipes {
if r.Name() == config.Base {
baseRecipe = r
break
}
}
}

Expand All @@ -186,6 +228,13 @@ func ParseYAMLRecipe(filePath string, baseRecipes []Recipe) (*YAMLRecipe, error)
}, nil
}

// isYAMLBasePath returns true if the base string looks like a file path
// rather than a built-in recipe name.
func isYAMLBasePath(base string) bool {
return strings.Contains(base, string(filepath.Separator)) || strings.Contains(base, "/") ||
strings.HasSuffix(base, ".yaml") || strings.HasSuffix(base, ".yml")
}
Comment thread
fkondej marked this conversation as resolved.

func (y *YAMLRecipe) Name() string {
return fmt.Sprintf("yaml(%s)", y.filePath)
}
Expand Down
172 changes: 172 additions & 0 deletions playground/recipe_yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -983,3 +983,175 @@ recipe:
require.Empty(t, svc.Start)
require.Equal(t, []string{"echo \"cleanup\""}, svc.Stop)
}

func TestIsYAMLBasePath(t *testing.T) {
// File paths should be detected
require.True(t, isYAMLBasePath("./base.yaml"))
require.True(t, isYAMLBasePath("../base.yaml"))
require.True(t, isYAMLBasePath("/absolute/path/base.yaml"))
require.True(t, isYAMLBasePath("subdir/base.yaml"))
require.True(t, isYAMLBasePath("recipe.yml"))

// Built-in names should not be detected as paths
require.False(t, isYAMLBasePath("l1"))
require.False(t, isYAMLBasePath("opstack"))
require.False(t, isYAMLBasePath("buildernet"))
}

func TestParseYAMLRecipe_FileBase(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recipe-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Base recipe: extends l1 with a service
baseContent := `base: l1
recipe:
base-component:
services:
base-svc:
image: base-image
tag: v1.0.0
`
baseFile := filepath.Join(tmpDir, "base-recipe.yaml")
require.NoError(t, os.WriteFile(baseFile, []byte(baseContent), 0o644))

// Child recipe: extends the base recipe file with another service
childContent := `base: ./base-recipe.yaml
recipe:
child-component:
services:
child-svc:
image: child-image
tag: v2.0.0
`
childFile := filepath.Join(tmpDir, "child-recipe.yaml")
require.NoError(t, os.WriteFile(childFile, []byte(childContent), 0o644))

baseRecipes := GetBaseRecipes()
recipe, err := ParseYAMLRecipe(childFile, baseRecipes)
require.NoError(t, err)
require.NotNil(t, recipe)

// Apply and verify both base and child services exist
exCtx := &ExContext{
LogLevel: LevelInfo,
Contender: &ContenderContext{
Enabled: false,
},
}
component := recipe.Apply(exCtx)

baseSvc := component.FindService("base-svc")
require.NotNil(t, baseSvc, "base service should exist")

childSvc := component.FindService("child-svc")
require.NotNil(t, childSvc, "child service should exist")
}

func TestParseYAMLRecipe_FileBase_CircularDependency(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recipe-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Recipe A references B
recipeA := `base: ./recipe-b.yaml
recipe: {}
`
fileA := filepath.Join(tmpDir, "recipe-a.yaml")
require.NoError(t, os.WriteFile(fileA, []byte(recipeA), 0o644))

// Recipe B references A (circular)
recipeB := `base: ./recipe-a.yaml
recipe: {}
`
fileB := filepath.Join(tmpDir, "recipe-b.yaml")
require.NoError(t, os.WriteFile(fileB, []byte(recipeB), 0o644))

baseRecipes := GetBaseRecipes()
_, err = ParseYAMLRecipe(fileA, baseRecipes)
require.Error(t, err)
require.Contains(t, err.Error(), "circular")
}

func TestParseYAMLRecipe_FileBase_NotFound(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recipe-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

yamlContent := `base: ./nonexistent.yaml
recipe: {}
`
yamlFile := filepath.Join(tmpDir, "recipe.yaml")
require.NoError(t, os.WriteFile(yamlFile, []byte(yamlContent), 0o644))

baseRecipes := GetBaseRecipes()
_, err = ParseYAMLRecipe(yamlFile, baseRecipes)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to parse base recipe")
}

func TestParseYAMLRecipe_FileBase_MultiLevel(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "recipe-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Level 1: extends l1
level1 := `base: l1
recipe:
level1-component:
services:
level1-svc:
image: level1-image
tag: v1.0.0
`
file1 := filepath.Join(tmpDir, "level1.yaml")
require.NoError(t, os.WriteFile(file1, []byte(level1), 0o644))

// Level 2: extends level 1
level2 := `base: ./level1.yaml
recipe:
level2-component:
services:
level2-svc:
image: level2-image
tag: v2.0.0
`
file2 := filepath.Join(tmpDir, "level2.yaml")
require.NoError(t, os.WriteFile(file2, []byte(level2), 0o644))

// Level 3: extends level 2
level3 := `base: ./level2.yaml
recipe:
level3-component:
services:
level3-svc:
lifecycle_hooks: true
init:
- echo hello
start: sleep infinity
stop:
- echo bye
`
file3 := filepath.Join(tmpDir, "level3.yaml")
require.NoError(t, os.WriteFile(file3, []byte(level3), 0o644))

baseRecipes := GetBaseRecipes()
recipe, err := ParseYAMLRecipe(file3, baseRecipes)
require.NoError(t, err)

exCtx := &ExContext{
LogLevel: LevelInfo,
Contender: &ContenderContext{
Enabled: false,
},
}
component := recipe.Apply(exCtx)

// All three levels should be present
require.NotNil(t, component.FindService("level1-svc"), "level1 service should exist")
require.NotNil(t, component.FindService("level2-svc"), "level2 service should exist")

level3Svc := component.FindService("level3-svc")
require.NotNil(t, level3Svc, "level3 service should exist")
require.True(t, level3Svc.LifecycleHooks)
}
Loading