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 + + 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/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: 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 +}