Repository: score-spec/score-compose (go)

Issue:
[feature request] add native support for `dns` and `route` resources
The native `dns` resource should just output `localhost` as it's dns name every time.

The `route` resource should setup an nginx proxy on port 443 and route requests by prefix to the target workload services. It should validate overlapping routes and throw an error or recommend the user uses a different dns resource provisioner.


Pull request:
feat(provisioners): added dns.default and route.default provisioners
This PR adds initial basic provisioner support for the dns and route resource types following the same patterns already established by score-humanitec and similar implementations.

The dns provisioner implementation is intentionally simple: it just outputs `host=localhost`. More advanced implementations can do more clever things like generating random names, setting up a dns resolver, or modifying /etc/hosts.

The route resource sets up an nginx service published on port 8080 with host+path routing to the desired workload services.

Fixes #85 

Patch:
diff --git a/examples/09-dns-and-route/README.md b/examples/09-dns-and-route/README.md
new file mode 100644
index 0000000..a3ce662
--- /dev/null
+++ b/examples/09-dns-and-route/README.md
@@ -0,0 +1,43 @@
+# 09 - dns and route resources
+
+In previous examples, we showed how Score workloads can export service ports for other workloads to communicate with. However, this only exposes ports internally over some internal network and doesn't guarantee that these ports can be exposed over a public or external network.
+
+To do this, we have a `dns` resource to allocate an external dns name, and `route` to to specify an HTTP path to route to the specified service port. Notably this only supports tcp services that are hosting an HTTP protocol and won't work for other more complicated routing.
+
+Our score file now includes these 2 resources:
+
+```yaml
+apiVersion: score.dev/v1b1
+metadata:
+  name: hello-world
+service:
+  web:
+    port: 8080
+    targetPort: 80
+containers:
+  web:
+    image: nginx
+resources:
+  dns:
+    type: dns
+  route:
+    type: route
+    params:
+      path: /subpath
+      host: ${resources.dns.host}
+      port: web
+```
+
+The `route` resource indicates the host from the dns resource, and the sub path to route. While the `web` port is the one exposed by the service.
+
+This adds an additional nginx service to the compose file which contains an HTTP routing specification for the hostname and path combinations.
+
+By default, this listens on http://localhost:8080.
+
+## Limitations
+
+Technically, the `dns` resource should produce random or appropriate hostnames for each dns resource. However, this implementation always generates localhost since we don't by default create real DNS names or modify the local /etc/hosts file.
+
+This means that if there are multiple workloads with overlapping path routing, they will fail or only one will resolve correctly.
+
+To get around this, you may inject your own dns resource provisioner with appropriate behavior for your environment, the only requirement is that it returns a `host` output string that is resolvable.
diff --git a/examples/09-dns-and-route/score.yaml b/examples/09-dns-and-route/score.yaml
new file mode 100644
index 0000000..512a763
--- /dev/null
+++ b/examples/09-dns-and-route/score.yaml
@@ -0,0 +1,20 @@
+apiVersion: score.dev/v1b1
+metadata:
+  name: hello-world
+service:
+  ports:
+    web:
+      port: 8080
+      targetPort: 80
+containers:
+  web:
+    image: nginx
+resources:
+  dns:
+    type: dns
+  route:
+    type: route
+    params:
+      path: /subpath
+      host: ${resources.dns.host}
+      port: web
\ No newline at end of file
diff --git a/internal/command/default.provisioners.yaml b/internal/command/default.provisioners.yaml
index 40669c0..974451d 100644
--- a/internal/command/default.provisioners.yaml
+++ b/internal/command/default.provisioners.yaml
@@ -275,3 +275,109 @@
     {{ if ne .Init.publishPort 0 }}
     - "Or enter {{ dig .Init.sk "instanceUsername" "" .Shared }} / {{ dig .Init.sk "instancePassword" "" .Shared }} at https://localhost:{{ .Init.publishPort }}"
     {{ end }}
+
+# The default dns provisioner just outputs localhost as the hostname every time.
+# This is because without actual control of a dns resolver we can't do any accurate routing on any other name. This
+# can be replaced by a new provisioner in the future.
+- uri: template://default-provisioners/dns
+  type: dns
+  class: default
+  outputs: |
+    host: localhost
+
+# The default route provisioner sets up an nginx service with an HTTP service that can route on our prefix paths.
+# It assumes the hostnames and routes provided have no overlaps. Weird behavior may happen if there are overlaps.
+- uri: template://default-provisioners/route
+  type: route
+  class: default
+  init: |
+    randomServiceName: routing-{{ randAlphaNum 6 }}
+    sk: default-provisioners-routing-instance
+    {{ if not (regexMatch "^/|(/([^/]+))+$" .Params.path) }}{{ fail "params.path start with a / but cannot end with /" }}{{ end }}
+    {{ if not (regexMatch "^[a-z0-9_.-]{1,253}$" .Params.host) }}{{ fail "params.host must be a valid hostname" }}{{ end }}
+    {{ $ports := (index .WorkloadServices .SourceWorkload).Ports }}
+    {{ if not $ports }}{{ fail "no service ports exist" }}{{ end }}
+    {{ $port := index $ports (print .Params.port) }}
+    {{ if not $port.TargetPort }}{{ fail "params.port is not a named service port" }}{{ end }}
+  shared: |
+    {{ .Init.sk }}:
+      instancePort: 8080
+      instanceServiceName: {{ dig .Init.sk "instanceServiceName" .Init.randomServiceName .Shared | quote }}
+      {{ $targetHost := (index .WorkloadServices .SourceWorkload).ServiceName }}
+      {{ $ports := (index .WorkloadServices .SourceWorkload).Ports }}
+      {{ $port := index $ports (print .Params.port) }}
+      {{ $targetPort := $port.TargetPort }}
+      {{ $target := (printf "%s:%d" $targetHost $targetPort) }}
+      {{ $hBefore := dig .Init.sk "hosts" (dict) .Shared }}
+      {{ $rBefore := dig .Params.host (dict) $hBefore }}
+      {{ $inner := dict "path" .Params.path "target" $target "port" $targetPort }}
+      {{ $rAfter := (merge $rBefore (dict .Uid $inner)) }}
+      {{ $hAfter := (merge $hBefore (dict .Params.host $rAfter)) }}
+      hosts: {{ $hAfter | toRawJson }}
+  files: |
+    {{ dig .Init.sk "instanceServiceName" "" .Shared }}/nginx.conf: |
+      worker_processes 1;
+      worker_rlimit_nofile 8192;
+      events {
+        worker_connections 4096;
+      }
+      http {
+        resolver 127.0.0.11;
+    
+        {{ range $h, $r := (dig .Init.sk "hosts" "" .Shared) }}
+        server {
+          listen 80;
+          listen [::]:80;
+          server_name {{ $h }};
+    
+          proxy_set_header X-Real-IP              $remote_addr;
+          proxy_set_header X-Forwarded-For        $remote_addr;
+          proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
+          proxy_set_header Proxy                  "";
+          proxy_connect_timeout                   5s;
+          proxy_send_timeout                      60s;
+          proxy_read_timeout                      60s;
+          proxy_buffers                           16 4k;
+          proxy_buffer_size                       2k;
+          
+          location = /favicon.ico {
+            return 204;
+            access_log     off;
+            log_not_found  off;
+          }
+          
+          {{ range $k, $v := $r }}
+          location = {{ index $v "path" }} {
+            set $backend {{ index $v "target" }};
+            rewrite ^{{ index $v "path" }}(.*)$ /$1 break;
+            proxy_pass http://$backend;
+          }
+
+          {{ if not (eq (index $v "path") "/") }}
+          location {{ index $v "path" }}/ {
+            set $backend {{ index $v "target" }};
+            rewrite ^{{ index $v "path" }}/(.*)$ /$1 break;
+            proxy_pass http://$backend;
+          }
+          {{ end }}
+          {{ end }}
+        }
+        {{ end }}
+      }
+
+  services: |
+    {{ $p := (dig .Init.sk "instancePort" 0 .Shared) }}
+    {{ dig .Init.sk "instanceServiceName" "" .Shared }}:
+      image: "nginx:1"
+      restart: always
+      ports:
+        - published: {{ $p }}
+          target: 80
+      volumes:
+        - type: bind
+          source: {{ .MountsDirectory }}/{{ dig .Init.sk "instanceServiceName" "" .Shared }}/nginx.conf
+          target: /etc/nginx/nginx.conf
+          readOnly: true
+  info_logs: |
+    {{ $p := (dig .Init.sk "instancePort" 0 .Shared) }}
+    - "{{.Uid}}: To connect to this route, http://{{ .Params.host }}:{{ $p }}{{ .Params.path }} (make sure {{ .Params.host }} resolves to localhost)"
diff --git a/internal/project/state.go b/internal/project/state.go
index 214fca3..a6419f5 100644
--- a/internal/project/state.go
+++ b/internal/project/state.go
@@ -32,9 +32,6 @@ type State struct {
 	SharedState        map[string]interface{}             `yaml:"shared_state"`
 	ComposeProjectName string                             `yaml:"compose_project"`
 	MountsDirectory    string                             `yaml:"mounts_directory"`
-
-	// IsDeprecatedPortPublishingEnabled enables the old score-compose run mechanism of publishing service ports
-	IsDeprecatedPortPublishingEnabled bool `yaml:"-"`
 }
 
 type ScoreWorkloadState struct {
diff --git a/internal/provisioners/core.go b/internal/provisioners/core.go
index 3206dbd..ba87250 100644
--- a/internal/provisioners/core.go
+++ b/internal/provisioners/core.go
@@ -23,6 +23,7 @@ import (
 	"os"
 	"path/filepath"
 	"slices"
+	"strconv"
 	"strings"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
@@ -259,6 +260,14 @@ func buildWorkloadServices(state *project.State) map[string]NetworkService {
 					Protocol:   util.DerefOr(port.Protocol, score.ServicePortProtocolTCP),
 				}
 			}
+			// Also add unique ports using a str-converted port number - this expands compatibility by allowing users
+			// to indicate the named port using its port number as a secondary name.
+			for s, port := range (*workloadState.Spec.Service).Ports {
+				p2 := strconv.Itoa(port.Port)
+				if _, ok := ns.Ports[p2]; !ok {
+					ns.Ports[p2] = ns.Ports[s]
+				}
+			}
 		}
 		out[workloadName] = ns
 	}


Test patch:
diff --git a/internal/command/generate_examples_test.go b/internal/command/generate_examples_test.go
index c7006f6..bdf1dbe 100644
--- a/internal/command/generate_examples_test.go
+++ b/internal/command/generate_examples_test.go
@@ -145,6 +145,10 @@ services:
         image: nginx
 `,
 		},
+		{
+			subDir: "09-dns-and-route",
+			adds:   []string{"score.yaml"},
+		},
 	} {
 		t.Run(tc.subDir, func(t *testing.T) {
 			oldReader := rand.Reader
diff --git a/internal/command/generate_test.go b/internal/command/generate_test.go
index 36d71e7..f411e31 100644
--- a/internal/command/generate_test.go
+++ b/internal/command/generate_test.go
@@ -773,3 +773,83 @@ services:
 		assert.NoError(t, cmd.Run())
 	})
 }
+
+func TestGenerateRouteResource(t *testing.T) {
+	td := changeToTempDir(t)
+	stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+	assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(`
+apiVersion: score.dev/v1b1
+metadata:
+  name: example
+containers:
+  example:
+    image: foo
+service:
+  ports:
+    foo:
+      port: 80
+      targetPort: 8080
+resources:
+  r1:
+    type: route
+    params:
+      host: localhost1
+      path: /first
+      port: foo
+  r2:
+    type: route
+    params:
+      host: localhost1
+      path: /second
+      port: foo
+  r3:
+    type: route
+    params:
+      host: localhost2
+      path: /third
+      port: 80
+`), 0644))
+	stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+
+	// check that state was persisted
+	sd, ok, err := project.LoadStateDirectory(td)
+	assert.NoError(t, err)
+	assert.True(t, ok)
+	assert.Len(t, sd.State.Workloads, 1)
+	assert.Len(t, sd.State.Resources, 3)
+	x := sd.State.SharedState["default-provisioners-routing-instance"].(map[string]interface{})
+	assert.Contains(t, x["instanceServiceName"].(string), "routing-")
+	delete(x, "instanceServiceName")
+	assert.Equal(t, map[string]interface{}{
+		"default-provisioners-routing-instance": map[string]interface{}{
+			"hosts": map[string]interface{}{
+				"localhost1": map[string]interface{}{
+					"route.default#example.r1": map[string]interface{}{"path": "/first", "port": 8080, "target": "example-example:8080"},
+					"route.default#example.r2": map[string]interface{}{"path": "/second", "port": 8080, "target": "example-example:8080"},
+				},
+				"localhost2": map[string]interface{}{
+					"route.default#example.r3": map[string]interface{}{"path": "/third", "port": 8080, "target": "example-example:8080"},
+				},
+			},
+			"instancePort": 8080,
+		},
+	}, sd.State.SharedState)
+
+	t.Run("validate compose spec", func(t *testing.T) {
+		if os.Getenv("NO_DOCKER") != "" {
+			t.Skip("NO_DOCKER is set")
+			return
+		}
+		dockerCmd, err := exec.LookPath("docker")
+		require.NoError(t, err)
+		cmd := exec.Command(dockerCmd, "compose", "-f", "compose.yaml", "convert", "--quiet", "--dry-run")
+		cmd.Dir = td
+		cmd.Stdout = os.Stdout
+		cmd.Stderr = os.Stderr
+		assert.NoError(t, cmd.Run())
+	})
+}
diff --git a/internal/provisioners/core_test.go b/internal/provisioners/core_test.go
index 5046382..685ca23 100644
--- a/internal/provisioners/core_test.go
+++ b/internal/provisioners/core_test.go
@@ -141,7 +141,9 @@ func TestProvisionResourcesWithNetworkService(t *testing.T) {
 					ServiceName: "w1-container-a",
 					Ports: map[string]ServicePort{
 						"web":  {Name: "web", Port: 80, TargetPort: 80, Protocol: score.ServicePortProtocolTCP},
+						"80":   {Name: "web", Port: 80, TargetPort: 80, Protocol: score.ServicePortProtocolTCP},
 						"grpc": {Name: "grpc", Port: 9000, TargetPort: 9001, Protocol: score.ServicePortProtocolUDP},
+						"9000": {Name: "grpc", Port: 9000, TargetPort: 9001, Protocol: score.ServicePortProtocolUDP},
 					},
 				},
 			}, input.WorkloadServices)