Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support score-compose init subcommand #57

Merged
merged 4 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions internal/command/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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 st, err := os.Stat(initCmdScoreFile); err != nil {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, it is not considered good practice to check for a file before opening - rather favour attempting to open the file and then check the failure there. This is to avoid race conditions for file opening i.e. https://groups.google.com/g/golang-nuts/c/Ayx-BMNdMFo/m/4rL8FFHr8v4J

In this case, using os.O_CREATE with os.O_EXCL will be an error if the file already exists.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed up :) now just doing a direct read with error handling and then a write if it doesn't exist.

if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to read 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 if st.IsDir() || !st.Mode().IsRegular() {
return fmt.Errorf("existing Score file is not a regular file")
} 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 initial score file to ensure exists")
initCmd.Flags().StringP("project", "p", "", "The name of the docker compose project")

rootCmd.AddCommand(initCmd)
}
108 changes: 108 additions & 0 deletions internal/command/init_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions internal/command/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
79 changes: 79 additions & 0 deletions internal/project/project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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)
if st, err := os.Stat(d); err != nil {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, it is not considered good practice to check for a file before opening - rather favour attempting to open the file and then check the failure there. This is to avoid race conditions for file opening i.e. https://groups.google.com/g/golang-nuts/c/Ayx-BMNdMFo/m/4rL8FFHr8v4J

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed up :) now just doing a direct read with error handling.

if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
}
return nil, false, fmt.Errorf("failed to stat '%s': %w", d, err)
} else if !st.IsDir() {
return nil, false, fmt.Errorf("path '%s' is not a directory", d)
}

content, err := os.ReadFile(filepath.Join(d, ConfigFileName))
if err != 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
}
Loading