Repository: score-spec/score-compose
Pull request #46 (2024-02-01 11:54:05)

feat: added support for multiple containers in the same workload
#### Description

This PR improves score-compose by allowing it to support multiple containers in the workload and still support published ports and services.

For example, running an api server and a backend worker in the same workload:

```
apiVersion: score.dev/v1b1
metadata:
  name: my-project

service:
  ports:
    api:
      port: 80
      targetPort: 8080
containers:
  api:
    image: my-project/my-image:0.0.1
    args: ["api"]
  worker:
    image: my-project/my-image:0.0.1
    args: ["worker"]
```

This produces a compose file that looks like:

```
services:
  my-project-api:
    command:
      - api
    image: my-project/my-image:0.0.1
    ports:
      - target: 8080
        published: "80"
  my-project-worker:
    command:
      - worker
    image: my-project/my-image:0.0.1
    network_mode: service:my-project-api
```

Note that the services have a container name appended, the ports are only present on the first service, and the other services inherit the network from the first.

#### What does this PR do?

1. Add all containers as services to the compose project
2. Add the container name as a suffix to build the service names
3. Share the first services network with the remaining services to ensure port resolution works as expected

Note, (3) emulates what Kubernetes and other "pod"-style runtimes do by placing all containers into the same network namespace, thus no two containers in the same workload can bind to the same port! The advantage though is that the published ports do not need to indicate a particular container.


#### Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] New chore (expected functionality to be implemented)

#### Checklist:
- [ ] My change requires a change to the documentation.
  - [ ] I have updated the documentation accordingly.
- [X] I've signed off with an email address that matches the commit author.



Patch:
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..8e31e23
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,19 @@
+services:
+  my-project-api:
+    command:
+      - /bin/sh
+      - -c
+      - sleep 2600
+    image: debian
+    ports:
+      - target: 8080
+        published: "80"
+    environment:
+      FOO: bar
+  my-project-worker:
+    command:
+      - /bin/sh
+      - -c
+      - sleep 2600
+    image: debian
+    network_mode: service:my-project-api
diff --git a/examples/03-dependencies/README.md b/examples/03-dependencies/README.md
index 0ef56af..451c581 100644
--- a/examples/03-dependencies/README.md
+++ b/examples/03-dependencies/README.md
@@ -61,11 +61,11 @@ Common place to store non-score defined configuration and resources is a root `c
 
 ```yaml
 services:
-  service-a:
+  service-a-service-a:
     depends_on:
       db:
         condition: service_started
-      service-b:
+      service-b-service-b:
         condition: service_started
   db:
     image: postgres:alpine
diff --git a/examples/03-dependencies/compose.yaml b/examples/03-dependencies/compose.yaml
index 3212971..6a1adbc 100644
--- a/examples/03-dependencies/compose.yaml
+++ b/examples/03-dependencies/compose.yaml
@@ -1,9 +1,9 @@
 services:
-  service-a:
+  service-a-service-a:
     depends_on:
       db:
         condition: service_started
-      service-b:
+      service-b-service-b:
         condition: service_started
   db:
     image: postgres:alpine
diff --git a/internal/command/run.go b/internal/command/run.go
index 23a32cf..2c412cb 100644
--- a/internal/command/run.go
+++ b/internal/command/run.go
@@ -174,11 +174,12 @@ func run(cmd *cobra.Command, args []string) error {
 	//
 	if buildCtx != "" {
 		log.Printf("Applying build instructions: '%s'...\n", buildCtx)
+		// We add the build context to all services and containers here and make a big assumption that all are
+		// using the image we are building here and now. If this is unexpected, users should use a more complex
+		// overrides file.
 		for idx := range proj.Services {
-			if proj.Services[idx].Name == spec.Metadata.Name {
-				proj.Services[idx].Build = &types.BuildConfig{Context: buildCtx}
-				proj.Services[idx].Image = ""
-			}
+			proj.Services[idx].Build = &types.BuildConfig{Context: buildCtx}
+			proj.Services[idx].Image = ""
 		}
 	}
 
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index 6baadce..0db93a1 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -23,25 +23,47 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, ExternalVariables, err
 		return nil, nil, fmt.Errorf("preparing context: %w", err)
 	}
 
-	for _, cSpec := range spec.Containers {
+	if len(spec.Containers) == 0 {
+		return nil, nil, errors.New("workload does not have any containers to convert into a compose service")
+	}
+
+	var project = compose.Project{
+		Services: make(compose.Services, 0, len(spec.Containers)),
+	}
+
+	externalVars := ExternalVariables(ctx.ListEnvVars())
+
+	var ports []compose.ServicePortConfig
+	if spec.Service != nil && len(spec.Service.Ports) > 0 {
+		ports = []compose.ServicePortConfig{}
+		for _, pSpec := range spec.Service.Ports {
+			var pubPort = fmt.Sprintf("%v", pSpec.Port)
+			ports = append(ports, compose.ServicePortConfig{
+				Published: pubPort,
+				Target:    uint32(DerefOr(pSpec.TargetPort, pSpec.Port)),
+				Protocol:  DerefOr(pSpec.Protocol, ""),
+			})
+		}
+	}
+
+	// When multiple containers are specified we need to identify one container as the "main" container which will own
+	// the network and use the native workload name. All other containers in this workload will have the container
+	// name appended as a suffix. We use the natural sort order of the container names and pick the first one
+	containerNames := make([]string, 0, len(spec.Containers))
+	for name := range spec.Containers {
+		containerNames = append(containerNames, name)
+	}
+	sort.Strings(containerNames)
+
+	for _, containerName := range containerNames {
+		cSpec := spec.Containers[containerName]
+
 		var env = make(compose.MappingWithEquals, len(cSpec.Variables))
 		for key, val := range cSpec.Variables {
 			var envVarVal = ctx.Substitute(val)
 			env[key] = &envVarVal
 		}
 
-		var ports []compose.ServicePortConfig
-		if spec.Service != nil && len(spec.Service.Ports) > 0 {
-			ports = []compose.ServicePortConfig{}
-			for _, pSpec := range spec.Service.Ports {
-				var pubPort = fmt.Sprintf("%v", pSpec.Port)
-				ports = append(ports, compose.ServicePortConfig{
-					Published: pubPort,
-					Target:    uint32(DerefOr(pSpec.TargetPort, pSpec.Port)),
-					Protocol:  DerefOr(pSpec.Protocol, ""),
-				})
-			}
-		}
 		// NOTE: Sorting is necessary for DeepEqual call within our Unit Tests to work reliably
 		sort.Slice(ports, func(i, j int) bool {
 			return ports[i].Published < ports[j].Published
@@ -70,7 +92,7 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, ExternalVariables, err
 		// END (NOTE)
 
 		var svc = compose.ServiceConfig{
-			Name:        spec.Metadata.Name,
+			Name:        spec.Metadata.Name + "-" + containerName,
 			Image:       cSpec.Image,
 			Entrypoint:  cSpec.Command,
 			Command:     cSpec.Args,
@@ -79,18 +101,13 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, ExternalVariables, err
 			Volumes:     volumes,
 		}
 
-		var proj = compose.Project{
-			Services: compose.Services{
-				svc,
-			},
+		// if we are not the "first" service, then inherit the network from the first service
+		if len(project.Services) > 0 {
+			svc.Ports = nil
+			svc.NetworkMode = "service:" + project.Services[0].Name
 		}
 
-		var externalVars = ExternalVariables(ctx.ListEnvVars())
-
-		// NOTE: Only one container per workload can be defined for compose.
-		//       All other containers will be ignored by this tool.
-		return &proj, externalVars, nil
+		project.Services = append(project.Services, svc)
 	}
-
-	return nil, nil, errors.New("workload does not have any containers to convert into a compose service")
+	return &project, externalVars, nil
 }
diff --git a/score.yaml b/score.yaml
new file mode 100644
index 0000000..482cab4
--- /dev/null
+++ b/score.yaml
@@ -0,0 +1,17 @@
+apiVersion: score.dev/v1b1
+metadata:
+  name: my-project
+
+service:
+  ports:
+    api:
+      port: 80
+      targetPort: 8080
+containers:
+  api:
+    image: debian
+    args: ["/bin/sh", "-c", "sleep 2600"]
+  worker:
+    image: debian
+    args: ["/bin/sh", "-c", "sleep 2600"]
+


Test patch:
diff --git a/e2e-tests/resources/outputs/example-01-hello-output.txt b/e2e-tests/resources/outputs/example-01-hello-output.txt
index 56558cb..7aed00b 100644
--- a/e2e-tests/resources/outputs/example-01-hello-output.txt
+++ b/e2e-tests/resources/outputs/example-01-hello-output.txt
@@ -1,5 +1,5 @@
 services:
-  hello-world:
+  hello-world-hello:
     command:
       - -c
       - while true; do echo Hello World!; sleep 5; done
diff --git a/e2e-tests/resources/outputs/example-02-environment-output.txt b/e2e-tests/resources/outputs/example-02-environment-output.txt
index ea4e3d9..0637e57 100644
--- a/e2e-tests/resources/outputs/example-02-environment-output.txt
+++ b/e2e-tests/resources/outputs/example-02-environment-output.txt
@@ -1,5 +1,5 @@
 services:
-  hello-world:
+  hello-world-hello:
     command:
       - -c
       - while true; do echo Hello $${FRIEND}!; sleep 5; done
diff --git a/e2e-tests/resources/outputs/example-03-dependencies-service-a-output.txt b/e2e-tests/resources/outputs/example-03-dependencies-service-a-output.txt
index 8425103..bd9cc26 100644
--- a/e2e-tests/resources/outputs/example-03-dependencies-service-a-output.txt
+++ b/e2e-tests/resources/outputs/example-03-dependencies-service-a-output.txt
@@ -1,5 +1,5 @@
 services:
-  service-a:
+  service-a-service-a:
     command:
       - -c
       - 'while true; do echo service-a: Hello $${FRIEND}! Connecting to $${CONNECTION_STRING}...; sleep 10; done'
diff --git a/e2e-tests/resources/outputs/example-03-dependencies-service-b-output.txt b/e2e-tests/resources/outputs/example-03-dependencies-service-b-output.txt
index 168b5f7..657c736 100644
--- a/e2e-tests/resources/outputs/example-03-dependencies-service-b-output.txt
+++ b/e2e-tests/resources/outputs/example-03-dependencies-service-b-output.txt
@@ -1,5 +1,5 @@
 services:
-  service-b:
+  service-b-service-b:
     command:
       - -c
       - 'while true; do echo service-b: Hello $${FRIEND}!; sleep 5; done'
diff --git a/e2e-tests/resources/outputs/example-04-extras-output.txt b/e2e-tests/resources/outputs/example-04-extras-output.txt
index 22a94f7..16b0820 100644
--- a/e2e-tests/resources/outputs/example-04-extras-output.txt
+++ b/e2e-tests/resources/outputs/example-04-extras-output.txt
@@ -1,5 +1,5 @@
 services:
-  web-app:
+  web-app-hello:
     image: nginx
     ports:
       - target: 80
diff --git a/e2e-tests/resources/outputs/run -f example-score.yaml --build test-output.txt b/e2e-tests/resources/outputs/run -f example-score.yaml --build test-output.txt
index 323fdad..e116804 100644
--- a/e2e-tests/resources/outputs/run -f example-score.yaml --build test-output.txt
+++ b/e2e-tests/resources/outputs/run -f example-score.yaml --build test-output.txt
@@ -1,5 +1,5 @@
 services:
-  hello-world:
+  hello-world-hello:
     build:
       context: test
     command:
diff --git a/e2e-tests/resources/outputs/run -f example-score.yaml --overrides overrides.yaml-output.txt b/e2e-tests/resources/outputs/run -f example-score.yaml --overrides overrides.yaml-output.txt
index fb4a6fd..34080a7 100644
--- a/e2e-tests/resources/outputs/run -f example-score.yaml --overrides overrides.yaml-output.txt
+++ b/e2e-tests/resources/outputs/run -f example-score.yaml --overrides overrides.yaml-output.txt
@@ -1,5 +1,5 @@
 services:
-  hello-world:
+  hello-world-hello:
     command:
       - -c
       - while true; do echo Hello World!; sleep 5; done
diff --git a/e2e-tests/resources/outputs/run -f example-score.yaml -p containers.hello.image=hello:1.1-output.txt b/e2e-tests/resources/outputs/run -f example-score.yaml -p containers.hello.image=hello:1.1-output.txt
index 0b4df55..d82f709 100644
--- a/e2e-tests/resources/outputs/run -f example-score.yaml -p containers.hello.image=hello:1.1-output.txt
+++ b/e2e-tests/resources/outputs/run -f example-score.yaml -p containers.hello.image=hello:1.1-output.txt
@@ -1,5 +1,5 @@
 services:
-  hello-world:
+  hello-world-hello:
     command:
       - -c
       - while true; do echo Hello World!; sleep 5; done
diff --git a/e2e-tests/resources/outputs/run -f example-score.yaml-output.txt b/e2e-tests/resources/outputs/run -f example-score.yaml-output.txt
index 56558cb..7aed00b 100644
--- a/e2e-tests/resources/outputs/run -f example-score.yaml-output.txt
+++ b/e2e-tests/resources/outputs/run -f example-score.yaml-output.txt
@@ -1,5 +1,5 @@
 services:
-  hello-world:
+  hello-world-hello:
     command:
       - -c
       - while true; do echo Hello World!; sleep 5; done
diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go
index ed42753..06acecc 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -67,7 +67,7 @@ func TestScoreConvert(t *testing.T) {
 			Project: &compose.Project{
 				Services: compose.Services{
 					{
-						Name:  "test",
+						Name:  "test-backend",
 						Image: "busybox",
 						Entrypoint: compose.ShellCommand{
 							"/bin/sh",
@@ -137,7 +137,7 @@ func TestScoreConvert(t *testing.T) {
 			Project: &compose.Project{
 				Services: compose.Services{
 					{
-						Name:  "test",
+						Name:  "test-backend",
 						Image: "busybox",
 						Environment: compose.MappingWithEquals{
 							"DEBUG":             stringPtr("${DEBUG}"),
@@ -163,6 +163,58 @@ func TestScoreConvert(t *testing.T) {
 				"APP_DB_NAME": "",
 			},
 		},
+		{
+			Name: "Should support multiple containers",
+			Source: &score.Workload{
+				Metadata: score.WorkloadMetadata{
+					Name: "test",
+				},
+				Containers: score.WorkloadContainers{
+					"frontend": score.Container{
+						Image: "busybox",
+						Variables: map[string]string{
+							"PORT": "80",
+						},
+					},
+					"backend": score.Container{
+						Image: "busybox",
+						Variables: map[string]string{
+							"PORT": "81",
+						},
+					},
+				},
+				Service: &score.WorkloadService{
+					Ports: map[string]score.ServicePort{
+						"frontend": {Port: 8080, TargetPort: Ref(80)},
+						"backend":  {Port: 8081, TargetPort: Ref(81)},
+					},
+				},
+			},
+			Project: &compose.Project{
+				Services: compose.Services{
+					{
+						Name:  "test-backend",
+						Image: "busybox",
+						Environment: compose.MappingWithEquals{
+							"PORT": stringPtr("81"),
+						},
+						Ports: []compose.ServicePortConfig{
+							{Target: 80, Published: "8080"},
+							{Target: 81, Published: "8081"},
+						},
+					},
+					{
+						Name:  "test-frontend",
+						Image: "busybox",
+						Environment: compose.MappingWithEquals{
+							"PORT": stringPtr("80"),
+						},
+						NetworkMode: "service:test-backend",
+					},
+				},
+			},
+			Vars: ExternalVariables{},
+		},
 
 		// Errors handling
 		//