Repository: compose-spec/compose-go
Pull request #571 (2024-02-12 14:57:36)

Add listeners to trace file metadata
## Description
This PR intends to retrieve metadata from the parsing file. This was an issue when tracking the occurrences of `extends` in a project for telemetry since at the time the metrics are sent the Project is fully resolved thus no extends instances.

This will be one out of three PRs.
https://github.com/docker/compose/pull/11492
## Interaction

```mermaid
sequenceDiagram
    participant pinata
    participant compose
    participant compose-go
    compose->>compose-go: register listeners 
    compose->>+compose-go: load project
    compose-go->>+compose-go/extends: ApplyExtends
   Note right of compose-go/extends: options.ProcessEvent("extends", metadata)
    compose-go/extends-->>-compose-go: finish extends
    compose-go-->>-compose: finish load
    compose-->>pinata: send OTEL metrics
```
## Fix
by adding the listener to increment the number of processed extends, found a bug where when going down the search tree, we were not updating the leafs after the merge. In a case where we had chained extends, we would over look the tree, since we would only update the `services` at the head of the recursion in `ApplyExtends`


Patch:
diff --git a/cli/options.go b/cli/options.go
index 01cb590..1e86753 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -80,6 +80,10 @@ type ProjectOptions struct {
 	EnvFiles []string
 
 	loadOptions []func(*loader.Options)
+
+	// Callbacks to retrieve metadata information during parse defined before
+	// creating the project
+	Listeners []loader.Listener
 }
 
 type ProjectOptionsFn func(*ProjectOptions) error
@@ -89,6 +93,7 @@ func NewProjectOptions(configs []string, opts ...ProjectOptionsFn) (*ProjectOpti
 	options := &ProjectOptions{
 		ConfigPaths: configs,
 		Environment: map[string]string{},
+		Listeners:   []loader.Listener{},
 	}
 	for _, o := range opts {
 		err := o(options)
@@ -365,6 +370,11 @@ func WithExtension(name string, typ any) ProjectOptionsFn {
 	}
 }
 
+// Append listener to event
+func (o *ProjectOptions) WithListeners(listeners ...loader.Listener) {
+	o.Listeners = append(o.Listeners, listeners...)
+}
+
 // WithoutEnvironmentResolution disable environment resolution
 func WithoutEnvironmentResolution(o *ProjectOptions) error {
 	o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
@@ -437,7 +447,8 @@ func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
 
 	options.loadOptions = append(options.loadOptions,
 		withNamePrecedenceLoad(absWorkingDir, options),
-		withConvertWindowsPaths(options))
+		withConvertWindowsPaths(options),
+		withListener(options))
 
 	ctx := options.ctx
 	if ctx == nil {
@@ -480,6 +491,13 @@ func withConvertWindowsPaths(options *ProjectOptions) func(*loader.Options) {
 	}
 }
 
+// save listeners from ProjectOptions (compose) to loader.Options
+func withListener(options *ProjectOptions) func(*loader.Options) {
+	return func(opts *loader.Options) {
+		opts.Listeners = options.Listeners
+	}
+}
+
 // getConfigPathsFromOptions retrieves the config files for project based on project options
 func getConfigPathsFromOptions(options *ProjectOptions) ([]string, error) {
 	if len(options.ConfigPaths) != 0 {
diff --git a/loader/extends.go b/loader/extends.go
index 862697e..07bf32b 100644
--- a/loader/extends.go
+++ b/loader/extends.go
@@ -70,8 +70,10 @@ func applyServiceExtends(ctx context.Context, name string, services map[string]a
 	case map[string]any:
 		ref = v["service"].(string)
 		file = v["file"]
+		opts.ProcessEvent("extends", v)
 	case string:
 		ref = v
+		opts.ProcessEvent("extends", map[string]any{"service": ref})
 	}
 
 	var base any
@@ -121,6 +123,7 @@ func applyServiceExtends(ctx context.Context, name string, services map[string]a
 		return nil, err
 	}
 	delete(merged, "extends")
+	services[name] = merged
 	return merged, nil
 }
 
diff --git a/loader/loader.go b/loader/loader.go
index bf570d3..dee07ee 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -80,6 +80,17 @@ type Options struct {
 	ResourceLoaders []ResourceLoader
 	// KnownExtensions manages x-* attribute we know and the corresponding go structs
 	KnownExtensions map[string]any
+	// Metada for telemetry
+	Listeners []Listener
+}
+
+type Listener = func(event string, metadata map[string]any)
+
+// Invoke all listeners for an event
+func (o *Options) ProcessEvent(event string, metadata map[string]any) {
+	for _, l := range o.Listeners {
+		l(event, metadata)
+	}
 }
 
 // ResourceLoader is a plugable remote resource resolver
@@ -153,6 +164,7 @@ func (o *Options) clone() *Options {
 		Profiles:                   o.Profiles,
 		ResourceLoaders:            o.ResourceLoaders,
 		KnownExtensions:            o.KnownExtensions,
+		Listeners:                  o.Listeners,
 	}
 }
 


Test patch:
diff --git a/loader/extends_test.go b/loader/extends_test.go
index 5399df6..1951627 100644
--- a/loader/extends_test.go
+++ b/loader/extends_test.go
@@ -52,6 +52,7 @@ services:
 	abs, err := filepath.Abs(".")
 	assert.NilError(t, err)
 
+	extendsCount := 0
 	p, err := LoadWithContext(context.Background(), types.ConfigDetails{
 		ConfigFiles: []types.ConfigFile{
 			{
@@ -60,11 +61,21 @@ services:
 			},
 		},
 		WorkingDir: abs,
+	}, func(options *Options) {
+		options.ResolvePaths = false
+		options.Listeners = []Listener{
+			func(event string, metadata map[string]any) {
+				if event == "extends" {
+					extendsCount++
+				}
+			},
+		}
 	})
 	assert.NilError(t, err)
 	assert.DeepEqual(t, p.Services["test1"].Hostname, "test1")
 	assert.Equal(t, p.Services["test2"].Hostname, "test2")
 	assert.Equal(t, p.Services["test3"].Hostname, "test3")
+	assert.Equal(t, extendsCount, 4)
 }
 
 func TestExtendsPort(t *testing.T) {
@@ -262,6 +273,7 @@ services:
 
 	assert.NilError(t, os.WriteFile(filepath.Join(tmpdir, "compose.yaml"), []byte(rootYAML), 0o600))
 
+	extendsCount := 0
 	actual, err := Load(types.ConfigDetails{
 		WorkingDir: tmpdir,
 		ConfigFiles: []types.ConfigFile{{
@@ -272,6 +284,13 @@ services:
 		options.SkipNormalization = true
 		options.SkipConsistencyCheck = true
 		options.SetProjectName("project", true)
+		options.Listeners = []Listener{
+			func(event string, metadata map[string]any) {
+				if event == "extends" {
+					extendsCount++
+				}
+			},
+		}
 	})
 	assert.NilError(t, err)
 	assert.Assert(t, is.Len(actual.Services, 2))
@@ -283,6 +302,8 @@ services:
 	svcB, err := actual.GetService("out-service")
 	assert.NilError(t, err)
 	assert.Equal(t, svcB.Build.Context, tmpdir)
+
+	assert.Equal(t, extendsCount, 3)
 }
 
 func TestRejectExtendsWithServiceRef(t *testing.T) {
@@ -358,3 +379,84 @@ services:
 		})
 	}
 }
+
+func TestLoadExtendsListener(t *testing.T) {
+	yaml := `
+  name: listener-extends
+  services:
+    foo:
+      image: busybox
+      extends: bar
+    bar:
+      image: alpine
+      command: echo
+      extends: wee
+    wee:
+      extends: last
+      command: echo
+    last:
+      image: python`
+	extendsCount := 0
+	_, err := Load(buildConfigDetails(yaml, nil), func(options *Options) {
+		options.SkipConsistencyCheck = true
+		options.SkipNormalization = true
+		options.ResolvePaths = true
+		options.Listeners = []Listener{
+			func(event string, metadata map[string]any) {
+				if event == "extends" {
+					extendsCount++
+				}
+			},
+		}
+	})
+
+	assert.NilError(t, err)
+	assert.Equal(t, extendsCount, 3)
+}
+
+func TestLoadExtendsListenerMultipleFiles(t *testing.T) {
+	tmpdir := t.TempDir()
+	subDir := filepath.Join(tmpdir, "sub")
+	assert.NilError(t, os.Mkdir(subDir, 0o700))
+	subYAML := `
+services:
+  b:
+    extends: c
+    build:
+      target: fake
+  c:
+    command: echo
+`
+	assert.NilError(t, os.WriteFile(filepath.Join(tmpdir, "sub", "compose.yaml"), []byte(subYAML), 0o600))
+
+	rootYAML := `
+services:
+  a:
+    extends:
+      file: ./sub/compose.yaml
+      service: b
+`
+	assert.NilError(t, os.WriteFile(filepath.Join(tmpdir, "compose.yaml"), []byte(rootYAML), 0o600))
+
+	extendsCount := 0
+	_, err := Load(types.ConfigDetails{
+		WorkingDir: tmpdir,
+		ConfigFiles: []types.ConfigFile{{
+			Filename: filepath.Join(tmpdir, "compose.yaml"),
+		}},
+		Environment: nil,
+	}, func(options *Options) {
+		options.SkipNormalization = true
+		options.SkipConsistencyCheck = true
+		options.SetProjectName("project", true)
+		options.Listeners = []Listener{
+			func(event string, metadata map[string]any) {
+				if event == "extends" {
+					extendsCount++
+				}
+			},
+		}
+	})
+	assert.NilError(t, err)
+	assert.Equal(t, extendsCount, 2)
+}