-
Notifications
You must be signed in to change notification settings - Fork 42
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: Ben Meier <[email protected]>
- Loading branch information
1 parent
5b0efd2
commit 9cc6205
Showing
4 changed files
with
323 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
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 | ||
} |