From aba328e4c0a4b6710b7e9590b9f5424530f65fe2 Mon Sep 17 00:00:00 2001 From: Berger Eugene Date: Sat, 2 Nov 2024 01:17:26 +0200 Subject: [PATCH] feat #270: Add configuration inheritance with extends --- default.nix | 1 - .../process-compose-with-extends.yaml | 5 + src/loader/loader.go | 43 ++++++++- src/loader/loader_test.go | 94 +++++++++++++++++++ src/types/project.go | 1 + www/docs/merge.md | 44 ++++++++- 6 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 fixtures-code/process-compose-with-extends.yaml diff --git a/default.nix b/default.nix index ef41ef8e..89e1140f 100644 --- a/default.nix +++ b/default.nix @@ -16,7 +16,6 @@ buildGoModule rec { "-s" "-w" ]; - CGO_ENABLED = 0; nativeBuildInputs = [ installShellFiles ]; diff --git a/fixtures-code/process-compose-with-extends.yaml b/fixtures-code/process-compose-with-extends.yaml new file mode 100644 index 00000000..4388349a --- /dev/null +++ b/fixtures-code/process-compose-with-extends.yaml @@ -0,0 +1,5 @@ +version: "0.5" +extends: process-compose-chain.yaml +processes: + process1: + command: "echo extending" diff --git a/src/loader/loader.go b/src/loader/loader.go index 6dea4d2c..ea23e6b1 100644 --- a/src/loader/loader.go +++ b/src/loader/loader.go @@ -9,6 +9,7 @@ import ( "gopkg.in/yaml.v2" "os" "path/filepath" + "slices" "strings" ) @@ -22,12 +23,19 @@ func Load(opts *LoaderOptions) (*types.Project, error) { return nil, err } - for _, file := range opts.FileNames { - p, err := loadProjectFromFile(file, opts) + for idx, file := range opts.FileNames { + prj, err := loadProjectFromFile(file, opts) if err != nil { return nil, err } - opts.projects = append(opts.projects, p) + err = loadExtendProject(prj, opts, file, idx) + if err != nil { + if opts.IsInternalLoader { + return nil, err + } + log.Fatal().Err(err).Send() + } + opts.projects = append(opts.projects, prj) } mergedProject, err := merge(opts) if err != nil { @@ -58,7 +66,7 @@ func Load(opts *LoaderOptions) (*types.Project, error) { validateProcessConfig, validateNoCircularDependencies, validateShellConfig, - validateDependenciesExist, + validateDependenciesExist, validatePlatformCompatibility, validateHealthDependencyHasHealthCheck, validateDependencyIsEnabled, @@ -68,6 +76,30 @@ func Load(opts *LoaderOptions) (*types.Project, error) { return mergedProject, err } +func loadExtendProject(p *types.Project, opts *LoaderOptions, file string, index int) error { + if p.ExtendsProject != "" { + if !filepath.IsAbs(p.ExtendsProject) { + p.ExtendsProject = filepath.Join(filepath.Dir(file), p.ExtendsProject) + } + if slices.Contains(opts.FileNames, p.ExtendsProject) { + log.Error().Msgf("Project %s extends itself", p.ExtendsProject) + return fmt.Errorf("project %s is already specified in files to load", p.ExtendsProject) + } + opts.FileNames = slices.Insert(opts.FileNames, index, p.ExtendsProject) + project, err := loadProjectFromFile(p.ExtendsProject, opts) + if err != nil { + log.Error().Err(err).Msgf("Failed to load the extend project %s", p.ExtendsProject) + return fmt.Errorf("failed to load extend project %s: %w", p.ExtendsProject, err) + } + opts.projects = slices.Insert(opts.projects, index, project) + err = loadExtendProject(project, opts, p.ExtendsProject, index) + if err != nil { + return fmt.Errorf("failed to load extend project %s: %w", p.ExtendsProject, err) + } + } + return nil +} + func admitProcesses(opts *LoaderOptions, p *types.Project) *types.Project { if opts.admitters == nil { return p @@ -89,6 +121,9 @@ func loadProjectFromFile(inputFile string, opts *LoaderOptions) (*types.Project, if errors.Is(err, os.ErrNotExist) { log.Error().Msgf("File %s doesn't exist", inputFile) } + if opts.IsInternalLoader { + return nil, err + } log.Fatal().Err(err).Msgf("Failed to read %s", inputFile) } diff --git a/src/loader/loader_test.go b/src/loader/loader_test.go index 6492cf52..9826aaea 100644 --- a/src/loader/loader_test.go +++ b/src/loader/loader_test.go @@ -1,6 +1,7 @@ package loader import ( + "path/filepath" "testing" ) @@ -61,3 +62,96 @@ func Test_autoDiscoverComposeFile(t *testing.T) { }) } } + +func TestLoadExtendProject(t *testing.T) { + fixture := filepath.Join("..", "..", "fixtures-code", "process-compose-with-log.yaml") + opts := &LoaderOptions{ + FileNames: []string{fixture}, + IsInternalLoader: true, + } + project, err := Load(opts) + if err != nil { + t.Error("failed to load project", err.Error()) + return + } + t.Run("no extend", func(t *testing.T) { + err = loadExtendProject(project, opts, "", 0) + if err != nil { + t.Error("failed to load project", err.Error()) + return + } + if len(opts.projects) != 1 { + t.Errorf("expected 1 project, got %d", len(opts.projects)) + } + }) + t.Run("extend", func(t *testing.T) { + project.ExtendsProject = "process-compose-chain.yaml" + err = loadExtendProject(project, opts, fixture, 0) + if err != nil { + t.Error("failed to load project", err.Error()) + return + } + if len(opts.projects) != 2 { + t.Errorf("expected 2 projects, got %d", len(opts.projects)) + return + } + if len(opts.FileNames) != 2 { + t.Errorf("expected 2 files, got %d", len(opts.FileNames)) + return + } + //check files order + if opts.FileNames[0] != project.ExtendsProject { + t.Errorf("expected %s, got %s", project.ExtendsProject, opts.FileNames[0]) + } + if opts.FileNames[1] != fixture { + t.Errorf("expected %s, got %s", fixture, opts.FileNames[1]) + } + }) + t.Run("prevent same project", func(t *testing.T) { + project.ExtendsProject = filepath.Base(fixture) + err = loadExtendProject(project, opts, fixture, 0) + if err == nil { + t.Error("expected error for same project, got nil") + return + } + }) + t.Run("missing file", func(t *testing.T) { + project.ExtendsProject = "missing.yaml" + err = loadExtendProject(project, opts, "", 0) + if err == nil { + t.Error("expected error for missing extend project file, got nil") + return + } + }) +} + +func TestLoadFileWithExtendProject(t *testing.T) { + fixture := filepath.Join("..", "..", "fixtures-code", "process-compose-with-extends.yaml") + opts := &LoaderOptions{ + FileNames: []string{fixture}, + IsInternalLoader: true, + } + project, err := Load(opts) + if err != nil { + t.Error("failed to load project", err.Error()) + return + } + if len(opts.projects) != 2 { + t.Errorf("expected 2 project, got %d", len(opts.projects)) + } + if len(opts.FileNames) != 2 { + t.Fatalf("expected 2 file, got %d", len(opts.FileNames)) + } + + //check files order + expected := filepath.Join("..", "..", "fixtures-code", "process-compose-chain.yaml") + if opts.FileNames[0] != expected { + t.Errorf("expected %s, got %s", expected, opts.FileNames[1]) + } + if opts.FileNames[1] != fixture { + t.Errorf("expected %s, got %s", fixture, opts.FileNames[0]) + } + if project.Processes["process1"].Command != "echo extending" { + t.Errorf("expected %s, got %s", "echo extending", project.Processes["process1"].Command) + } +} diff --git a/src/types/project.go b/src/types/project.go index b21ffe95..09f5fce2 100644 --- a/src/types/project.go +++ b/src/types/project.go @@ -22,6 +22,7 @@ type Project struct { Vars Vars `yaml:"vars"` DisableEnvExpansion bool `yaml:"disable_env_expansion"` IsTuiDisabled bool `yaml:"is_tui_disabled"` + ExtendsProject string `yaml:"extends,omitempty"` FileNames []string EnvFileNames []string } diff --git a/www/docs/merge.md b/www/docs/merge.md index 2716fd46..a988e6a5 100644 --- a/www/docs/merge.md +++ b/www/docs/merge.md @@ -144,4 +144,46 @@ processes: - "A=4" - "B=5" - "C=8" -``` \ No newline at end of file +``` + +### Configuration Inheritance with `extends` + +`process-compose` provides the `extends` keyword to simplify configuration file inheritance: + +```yaml +# ./some/dir/process-compose.prod.yaml +version: "0.5" +extends: "process-compose.yaml" + +processes: +``` + +```yaml +# ./some/dir/process-compose.yaml +version: "0.5" + +processes: +``` + +This is equivalent to running: + +```shell +$ process-compose -f ./some/dir/process-compose.yaml -f ./some/dir/process-compose.prod.yaml +``` + +And allows you to use the shorter command: + +```shell +$ process-compose -f ./some/dir/process-compose.prod.yaml +``` + +With the same result. + +**Notes**: + +1. Inheritance chains are limited only by available memory. +2. Circular inheritance will cause loading to fail. +3. The `extends` path is relative to the extending file's location (as shown in the example above). +4. Absolute paths are automatically detected and used as-is. +5. The `.env` file is loaded only from the `CWD`. Additional env files can be specified using `--env` (`-e`). +6. If file `B` uses the `extends` keyword to extend file `A`, loading both with `process-compose up -f A -f B` will fail. Load only the last file in the chain with `process-compose -f B` instead. \ No newline at end of file