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

Issue:
[feature request] add --no-sample option for score-compose init
When score-compose is being used in a directory which already has a score file but it's not named `score.yaml` it may be useful to skip generating the default score.yaml file to avoid creating confusion and garbage.

For example if I have a monorepo with 2 score projects `frontend/score.yaml` and `backend/score.yaml` I don't want an additional score.yaml file to be created in the top level directory so I should run `score-compose init --no-sample` which will avoid the sample being generated.



Pull request description:
feat: added init --no-sample to disable default score generation
Fixes #98 , this ensures that a CI/CD system that may be using score can intentionally disable the default generation and run the following in an existing project:

```
score-compose init --no-sample
score-compose generate score*.yaml
```


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/command/init.go b/internal/command/init.go
index f29dd99..f4a5a03 100644
--- a/internal/command/init.go
+++ b/internal/command/init.go
@@ -29,7 +29,8 @@ import (
 	"github.com/score-spec/score-compose/internal/provisioners/loader"
 )
 
-const DefaultScoreFileContent = `# Score provides a developer-centric and platform-agnostic 
+const (
+	DefaultScoreFileContent = `# Score provides a developer-centric and platform-agnostic 
 # Workload specification to improve developer productivity and experience. 
 # Score eliminates configuration management between local and remote environments.
 #
@@ -65,6 +66,10 @@ service:
 
 resources: {}
 `
+	initCmdFileFlag         = "file"
+	initCmdFileProjectFlag  = "project"
+	initCmdFileNoSampleFlag = "no-sample"
+)
 
 //go:embed default.provisioners.yaml
 var defaultProvisionersContent string
@@ -87,7 +92,10 @@ acts as a namespace when multiple score files and containers are used.
   score-compose init --file score2.yaml
 
   # Or override the docker compose project name
-  score-compose init --project score-compose2`,
+  score-compose init --project score-compose2
+
+  # Or disable the default score file generation if you already have a score file
+  score-compose init --no-sample`,
 
 	// don't print the errors - we print these ourselves in main()
 	SilenceErrors: true,
@@ -96,8 +104,8 @@ acts as a namespace when multiple score files and containers are used.
 		cmd.SilenceUsage = true
 
 		// load flag values
-		initCmdScoreFile, _ := cmd.Flags().GetString("file")
-		initCmdComposeProject, _ := cmd.Flags().GetString("project")
+		initCmdScoreFile, _ := cmd.Flags().GetString(initCmdFileFlag)
+		initCmdComposeProject, _ := cmd.Flags().GetString(initCmdFileProjectFlag)
 
 		// validate project
 		if initCmdComposeProject != "" {
@@ -148,14 +156,18 @@ acts as a namespace when multiple score files and containers are used.
 		}
 
 		if _, err := os.ReadFile(initCmdScoreFile); err != nil {
-			if !errors.Is(err, os.ErrNotExist) {
-				return fmt.Errorf("failed to check existing Score file: %w", err)
-			}
-			slog.Info(fmt.Sprintf("Initial Score file '%s' does not exist - creating it", initCmdScoreFile))
-			if err := os.WriteFile(initCmdScoreFile+".temp", []byte(DefaultScoreFileContent), 0755); err != nil {
-				return fmt.Errorf("failed to write initial score file: %w", err)
-			} else if err := os.Rename(initCmdScoreFile+".temp", initCmdScoreFile); err != nil {
-				return fmt.Errorf("failed to complete writing initial Score file: %w", err)
+			if v, _ := cmd.Flags().GetBool(initCmdFileNoSampleFlag); v {
+				slog.Info(fmt.Sprintf("Initial Score file '%s' does not exist - and sample generation is disabled", initCmdScoreFile))
+			} else {
+				if !errors.Is(err, os.ErrNotExist) {
+					return fmt.Errorf("failed to check existing Score file: %w", err)
+				}
+				slog.Info(fmt.Sprintf("Initial Score file '%s' does not exist - creating it", initCmdScoreFile))
+				if err := os.WriteFile(initCmdScoreFile+".temp", []byte(DefaultScoreFileContent), 0755); err != nil {
+					return fmt.Errorf("failed to write initial score file: %w", err)
+				} else if err := os.Rename(initCmdScoreFile+".temp", initCmdScoreFile); err != nil {
+					return fmt.Errorf("failed to complete writing initial Score file: %w", err)
+				}
 			}
 		} else {
 			slog.Info(fmt.Sprintf("Found existing Score file '%s'", initCmdScoreFile))
@@ -174,8 +186,9 @@ acts as a namespace when multiple score files and containers are used.
 }
 
 func init() {
-	initCmd.Flags().StringP("file", "f", scoreFileDefault, "The score file to initialize")
-	initCmd.Flags().StringP("project", "p", "", "Set the name of the docker compose project (defaults to the current directory name)")
+	initCmd.Flags().StringP(initCmdFileFlag, "f", scoreFileDefault, "The score file to initialize")
+	initCmd.Flags().StringP(initCmdFileProjectFlag, "p", "", "Set the name of the docker compose project (defaults to the current directory name)")
+	initCmd.Flags().Bool(initCmdFileNoSampleFlag, false, "Disable generation of the sample score file")
 
 	rootCmd.AddCommand(initCmd)
 }
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/command/init_test.go b/internal/command/init_test.go
index 9999ae0..0830e6a 100644
--- a/internal/command/init_test.go
+++ b/internal/command/init_test.go
@@ -27,6 +27,50 @@ import (
 	"github.com/score-spec/score-compose/internal/project"
 )
 
+func TestInitHelp(t *testing.T) {
+	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--help"})
+	assert.NoError(t, err)
+	assert.Equal(t, `The init subcommand will prepare the current directory for working with score-compose and prepare any local
+files or configuration needed to be successful.
+
+A directory named .score-compose will be created if it doesn't exist. This file stores local state and generally should
+not be checked into source control. Add it to your .gitignore file if you use Git as version control.
+
+The project name will be used as a Docker compose project name when the final compose files are written. This name
+acts as a namespace when multiple score files and containers are used.
+
+Usage:
+  score-compose init [flags]
+
+Examples:
+
+  # Define a score file to generate
+  score-compose init --file score2.yaml
+
+  # Or override the docker compose project name
+  score-compose init --project score-compose2
+
+  # Or disable the default score file generation if you already have a score file
+  score-compose init --no-sample
+
+Flags:
+  -f, --file string      The score file to initialize (default "./score.yaml")
+  -h, --help             help for init
+      --no-sample        Disable generation of the sample score file
+  -p, --project string   Set the name of the docker compose project (defaults to the current directory name)
+
+Global Flags:
+      --quiet           Mute any logging output
+  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
+`, stdout)
+	assert.Equal(t, "", stderr)
+
+	stdout2, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"help", "init"})
+	assert.NoError(t, err)
+	assert.Equal(t, stdout, stdout2)
+	assert.Equal(t, "", stderr)
+}
+
 func TestInitNominal(t *testing.T) {
 	td := t.TempDir()
 
@@ -66,6 +110,24 @@ func TestInitNominal(t *testing.T) {
 	}
 }
 
+func TestInitNoSample(t *testing.T) {
+	td := t.TempDir()
+
+	wd, _ := os.Getwd()
+	require.NoError(t, os.Chdir(td))
+	defer func() {
+		require.NoError(t, os.Chdir(wd))
+	}()
+
+	stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--no-sample"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
+
+	_, err = os.Stat("score.yaml")
+	assert.ErrorIs(t, err, os.ErrNotExist)
+}
+
 func TestInitNominal_custom_file_and_project(t *testing.T) {
 	td := t.TempDir()
 
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)