Repository: score-spec/score-compose
Pull request #57 (2024-03-04 17:38:17)

feat: support score-compose init subcommand
The init subcommand makes it easier to get started with Score compose!

```
$ score-compose init --help
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

Flags:
  -f, --file string      The initial score file to ensure exists (default "./score.yaml")
  -h, --help             help for init
  -p, --project string   The name of the docker compose project

Global Flags:
      --quiet           Mute any logging output
  -v, --verbose count   Increase log verbosity and detail by specifying this flag one or more times
```

And when run:

```
$ score-compose ../cmd/score-compose/main.go init
INFO: Writing new state directory '.score-compose'
INFO: Initial Score file './score.yaml' does not exist - creating it
INFO: Read more about the Score specification at https://docs.score.dev/docs/
```

This dumps out a score file that looks like:

![image](https://github.com/score-spec/score-compose/assets/1651305/82edf2a8-89b8-4c1c-a5f4-5e902838b1b3)


#### 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:
- [X] 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/README.md b/README.md
index 7e4f846..4886d99 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,10 @@
-![Score banner](docs/images/banner.png)
+# score-compose
 
-# ![Score](docs/images/logo.svg) Score overview
+`score-compose` is an implementation of the Score Workload specification for [Docker compose](https://docs.docker.com/compose/).
+
+## ![Score](docs/images/logo.svg) Score overview
+
+<img src="docs/images/banner.png" width="500px"/>
 
 Score aims to improve developer productivity and experience by reducing the risk of configuration inconsistencies between local and remote environments. It provides developer-centric workload specification (`score.yaml`) which captures a workloads runtime requirements in a platform-agnostic manner. Learn more [here](https://github.com/score-spec/spec#-what-is-score).
 
@@ -10,20 +14,37 @@ The `score.yaml` specification file can be executed against a _Score Implementat
 
 To install `score-compose`, follow the instructions as described in our [installation guide](https://docs.score.dev/docs/get-started/install/).
 
+You will also need a recent version of Docker and the Compose plugin installed. [Read more here](https://docs.docker.com/compose/install/).
+
 ## ![Get Started](docs/images/overview.svg) Get Started
 
-If you already have a `score.yaml` file defined, you can simply run the following command:
+If you're getting started, you can use `score-compose init` to create a basic `score.yaml` file in the current directory along with a `.score-compose/` working directory.
 
-```bash
-# Prepare a compose.yaml file
-score-compose run -f /tmp/score.yaml -o /tmp/compose.yaml
 ```
+$ score-compose init --help
+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.
 
-- `run` tells the CLI to translate the Score file to a Docker Compose file.
-- `-f` is the path to the Score file.
-- `-o` specifies the path to the output file.
+Usage:
+  score-compose init [flags]
+
+Flags:
+  -f, --file string      The score file to initialize (default "./score.yaml")
+  -h, --help             help for init
+  -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
+```
 
-If you're just getting started, follow [this guide](https://docs.score.dev/docs/get-started/score-compose-hello-world/) to run your first Hello World program with `score-compose`. The full usage of the `run` command is:
+Once you have a `score.yaml` file created, modify it by following [this guide](https://docs.score.dev/docs/get-started/score-compose-hello-world/), and use `score-compose run` to convert it into a Docker compose manifest:
 
 ```
 Translate the SCORE file to docker-compose configuration
diff --git a/internal/command/init.go b/internal/command/init.go
new file mode 100644
index 0000000..416fb6b
--- /dev/null
+++ b/internal/command/init.go
@@ -0,0 +1,132 @@
+package command
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+
+	"github.com/spf13/cobra"
+
+	"github.com/score-spec/score-compose/internal/project"
+)
+
+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.
+#
+# Specification reference: https://docs.score.dev/docs/reference/score-spec-reference/
+---
+
+# Score specification version
+apiVersion: score.dev/v1b1
+
+metadata:
+  name: example
+
+containers:
+  hello-world:
+    image: nginx:latest
+
+    # Uncomment the following for a custom entrypoint command
+    # command: []
+
+    # Uncomment the following for custom arguments
+    # args: []
+
+    # Environment variables to inject into the container
+    variables:
+      EXAMPLE_VARIABLE: "example-value"
+
+service:
+  ports:
+    # Expose the http port from nginx on port 8080
+    www:
+      port: 8080
+      targetPort: 80
+
+resources: {}
+`
+
+var initCmd = &cobra.Command{
+	Use:   "init",
+	Args:  cobra.NoArgs,
+	Short: "Initialise a new score-compose project with local state directory and score file",
+	Long: `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.
+`,
+	Example: `
+  # 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`,
+
+	// don't print the errors - we print these ourselves in main()
+	SilenceErrors: true,
+
+	RunE: func(cmd *cobra.Command, args []string) error {
+		cmd.SilenceUsage = true
+
+		// load flag values
+		initCmdScoreFile, _ := cmd.Flags().GetString("file")
+		initCmdComposeProject, _ := cmd.Flags().GetString("project")
+
+		sd, ok, err := project.LoadStateDirectory(".")
+		if err != nil {
+			return fmt.Errorf("failed to load existing state directory: %w", err)
+		} else if ok {
+			slog.Info(fmt.Sprintf("Found existing state directory '%s'", sd.Path))
+			if initCmdComposeProject != "" && sd.Config.ComposeProjectName != initCmdComposeProject {
+				sd.Config.ComposeProjectName = initCmdComposeProject
+				if err := sd.Persist(); err != nil {
+					return fmt.Errorf("failed to persist new compose project name: %w", err)
+				}
+			}
+		} else {
+			slog.Info(fmt.Sprintf("Writing new state directory '%s'", project.DefaultRelativeStateDirectory))
+			wd, _ := os.Getwd()
+			sd := &project.StateDirectory{
+				Path:   project.DefaultRelativeStateDirectory,
+				Config: project.Config{ComposeProjectName: filepath.Base(wd)},
+			}
+			if initCmdComposeProject != "" {
+				sd.Config.ComposeProjectName = initCmdComposeProject
+			}
+			if err := sd.Persist(); err != nil {
+				return fmt.Errorf("failed to persist new compose project name: %w", err)
+			}
+		}
+
+		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)
+			}
+		} else {
+			slog.Info(fmt.Sprintf("Found existing Score file '%s'", initCmdScoreFile))
+		}
+		slog.Info(fmt.Sprintf("Read more about the Score specification at https://docs.score.dev/docs/"))
+
+		return nil
+	},
+}
+
+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)")
+
+	rootCmd.AddCommand(initCmd)
+}
diff --git a/internal/project/project.go b/internal/project/project.go
new file mode 100644
index 0000000..7e7488d
--- /dev/null
+++ b/internal/project/project.go
@@ -0,0 +1,73 @@
+package project
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"gopkg.in/yaml.v3"
+)
+
+const (
+	DefaultRelativeStateDirectory = ".score-compose"
+	ConfigFileName                = "config.yaml"
+)
+
+// The StateDirectory holds the local state of the score-compose project, including any configuration, extensions,
+// plugins, or resource provisioning state when possible.
+type StateDirectory struct {
+	// The path to the .score-compose directory
+	Path string
+	// The current config read from the config.yaml file
+	Config Config
+}
+
+type Config struct {
+	ComposeProjectName string `yaml:"composeProject"`
+}
+
+// Persist ensures that the directory is created and that the current config file has been written with the latest settings.
+func (sd *StateDirectory) Persist() error {
+	if sd.Path == "" {
+		return fmt.Errorf("path not set")
+	}
+	if err := os.Mkdir(sd.Path, 0755); err != nil && !errors.Is(err, os.ErrExist) {
+		return fmt.Errorf("failed to create directory '%s': %w", sd.Path, err)
+	}
+	out := new(bytes.Buffer)
+	enc := yaml.NewEncoder(out)
+	enc.SetIndent(2)
+	if err := enc.Encode(sd.Config); err != nil {
+		return fmt.Errorf("failed to encode content: %w", err)
+	}
+
+	// important that we overwrite this file atomically via a inode move
+	if err := os.WriteFile(filepath.Join(sd.Path, ConfigFileName+".temp"), out.Bytes(), 0755); err != nil {
+		return fmt.Errorf("failed to write config: %w", err)
+	} else if err := os.Rename(filepath.Join(sd.Path, ConfigFileName+".temp"), filepath.Join(sd.Path, ConfigFileName)); err != nil {
+		return fmt.Errorf("failed to complete writing config: %w", err)
+	}
+	return nil
+}
+
+// LoadStateDirectory loads the state directory for the given directory (usually PWD).
+func LoadStateDirectory(directory string) (*StateDirectory, bool, error) {
+	d := filepath.Join(directory, DefaultRelativeStateDirectory)
+	content, err := os.ReadFile(filepath.Join(d, ConfigFileName))
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return nil, false, nil
+		}
+		return nil, true, fmt.Errorf("config file couldn't be read: %w", err)
+	}
+
+	var out Config
+	dec := yaml.NewDecoder(bytes.NewReader(content))
+	dec.KnownFields(true)
+	if err := dec.Decode(&out); err != nil {
+		return nil, true, fmt.Errorf("config file couldn't be decoded: %w", err)
+	}
+	return &StateDirectory{d, out}, true, nil
+}


Test patch:
diff --git a/internal/command/init_test.go b/internal/command/init_test.go
new file mode 100644
index 0000000..dc14df6
--- /dev/null
+++ b/internal/command/init_test.go
@@ -0,0 +1,108 @@
+package command
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/score-spec/score-compose/internal/project"
+)
+
+func TestInitNominal(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"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
+
+	stdout, stderr, err = executeAndResetCommand(context.Background(), rootCmd, []string{"run"})
+	assert.NoError(t, err)
+	assert.Equal(t, `services:
+  example-hello-world:
+    environment:
+      EXAMPLE_VARIABLE: example-value
+    image: nginx:latest
+    ports:
+      - target: 80
+        published: "8080"
+`, stdout)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
+
+	sd, ok, err := project.LoadStateDirectory(".")
+	assert.NoError(t, err)
+	if assert.True(t, ok) {
+		assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path)
+		assert.Equal(t, filepath.Base(td), sd.Config.ComposeProjectName)
+	}
+}
+
+func TestInitNominal_custom_file_and_project(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", "--file", "score2.yaml", "--project", "bananas"})
+	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)
+	_, err = os.Stat("score2.yaml")
+	assert.NoError(t, err)
+
+	sd, ok, err := project.LoadStateDirectory(".")
+	assert.NoError(t, err)
+	if assert.True(t, ok) {
+		assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path)
+		assert.Equal(t, "bananas", sd.Config.ComposeProjectName)
+	}
+}
+
+func TestInitNominal_run_twice(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", "--file", "score2.yaml", "--project", "bananas"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
+
+	stdout, stderr, err = executeAndResetCommand(context.Background(), rootCmd, []string{"init"})
+	assert.NoError(t, err)
+	assert.Equal(t, "", stdout)
+	assert.NotEqual(t, "", strings.TrimSpace(stderr))
+
+	_, err = os.Stat("score.yaml")
+	assert.NoError(t, err)
+	_, err = os.Stat("score2.yaml")
+	assert.NoError(t, err)
+
+	sd, ok, err := project.LoadStateDirectory(".")
+	assert.NoError(t, err)
+	if assert.True(t, ok) {
+		assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path)
+		assert.Equal(t, "bananas", sd.Config.ComposeProjectName)
+	}
+}
diff --git a/internal/command/root_test.go b/internal/command/root_test.go
index ae8f387..9d226d3 100644
--- a/internal/command/root_test.go
+++ b/internal/command/root_test.go
@@ -21,6 +21,7 @@ Usage:
 Available Commands:
   completion  Generate the autocompletion script for the specified shell
   help        Help about any command
+  init        Initialise a new score-compose project with local state directory and score file
   run         Translate the SCORE file to docker-compose configuration
 
 Flags: