Skip to content

Commit

Permalink
feat: added resource state model, priming, and legacy output generati…
Browse files Browse the repository at this point in the history
…on (#60)

Signed-off-by: Ben Meier <[email protected]>
  • Loading branch information
astromechza authored Mar 11, 2024
1 parent 94fa6a2 commit ad6960d
Show file tree
Hide file tree
Showing 11 changed files with 579 additions and 70 deletions.
41 changes: 36 additions & 5 deletions internal/command/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -79,27 +80,43 @@ acts as a namespace when multiple score files and containers are used.
initCmdScoreFile, _ := cmd.Flags().GetString("file")
initCmdComposeProject, _ := cmd.Flags().GetString("project")

// validate project
if initCmdComposeProject != "" {
cleanedInitCmdComposeProject := cleanComposeProjectName(initCmdComposeProject)
if cleanedInitCmdComposeProject != initCmdComposeProject {
return fmt.Errorf("invalid value for --project, it must match ^[a-z0-9][a-z0-9_-]*$")
}
}

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 initCmdComposeProject != "" && sd.State.ComposeProjectName != initCmdComposeProject {
sd.State.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)},
Path: project.DefaultRelativeStateDirectory,
State: project.State{
Workloads: map[string]project.ScoreWorkloadState{},
Resources: map[project.ResourceUid]project.ScoreResourceState{},
SharedState: map[string]interface{}{},
ComposeProjectName: cleanComposeProjectName(filepath.Base(wd)),
MountsDirectory: filepath.Join(project.DefaultRelativeStateDirectory, project.MountsDirectoryName),
},
}
if initCmdComposeProject != "" {
sd.Config.ComposeProjectName = initCmdComposeProject
sd.State.ComposeProjectName = initCmdComposeProject
}
slog.Info(fmt.Sprintf("Writing new state directory '%s' with project name '%s'", sd.Path, sd.State.ComposeProjectName))
if err := sd.Persist(); err != nil {
return fmt.Errorf("failed to persist new compose project name: %w", err)
}
Expand Down Expand Up @@ -130,3 +147,17 @@ func init() {

rootCmd.AddCommand(initCmd)
}

func cleanComposeProjectName(input string) string {
input = strings.ToLower(input)
isFirst := true
input = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || (!isFirst && ((r == '_') || (r == '-'))) {
isFirst = false
return r
}
isFirst = false
return -1
}, input)
return input
}
29 changes: 26 additions & 3 deletions internal/command/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ func TestInitNominal(t *testing.T) {
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)
assert.Equal(t, filepath.Base(td), sd.State.ComposeProjectName)
assert.Equal(t, filepath.Join(project.DefaultRelativeStateDirectory, "mounts"), sd.State.MountsDirectory)
assert.Equal(t, map[string]project.ScoreWorkloadState{}, sd.State.Workloads)
assert.Equal(t, map[project.ResourceUid]project.ScoreResourceState{}, sd.State.Resources)
assert.Equal(t, map[string]interface{}{}, sd.State.SharedState)
}
}

Expand All @@ -71,10 +75,25 @@ func TestInitNominal_custom_file_and_project(t *testing.T) {
assert.NoError(t, err)
if assert.True(t, ok) {
assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path)
assert.Equal(t, "bananas", sd.Config.ComposeProjectName)
assert.Equal(t, "bananas", sd.State.ComposeProjectName)
}
}

func TestInitNominal_bad_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", "--project", "-this-is-invalid-"})
assert.EqualError(t, err, "invalid value for --project, it must match ^[a-z0-9][a-z0-9_-]*$")
assert.Equal(t, "", stdout)
assert.Equal(t, "", stderr)
}

func TestInitNominal_run_twice(t *testing.T) {
td := t.TempDir()

Expand Down Expand Up @@ -103,6 +122,10 @@ func TestInitNominal_run_twice(t *testing.T) {
assert.NoError(t, err)
if assert.True(t, ok) {
assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path)
assert.Equal(t, "bananas", sd.Config.ComposeProjectName)
assert.Equal(t, "bananas", sd.State.ComposeProjectName)
assert.Equal(t, filepath.Join(project.DefaultRelativeStateDirectory, "mounts"), sd.State.MountsDirectory)
assert.Equal(t, map[string]project.ScoreWorkloadState{}, sd.State.Workloads)
assert.Equal(t, map[project.ResourceUid]project.ScoreResourceState{}, sd.State.Resources)
assert.Equal(t, map[string]interface{}{}, sd.State.SharedState)
}
}
56 changes: 55 additions & 1 deletion internal/command/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"fmt"
"io"
"log/slog"
"maps"
"os"
"sort"
"strings"
Expand All @@ -27,6 +28,7 @@ import (
score "github.com/score-spec/score-go/types"

"github.com/score-spec/score-compose/internal/compose"
"github.com/score-spec/score-compose/internal/project"
)

const (
Expand Down Expand Up @@ -169,10 +171,37 @@ func run(cmd *cobra.Command, args []string) error {
return fmt.Errorf("validating workload spec: %w", err)
}

// Build a fake score-compose init state. We don't actually need to store or persist this because we're not doing
// anything iterative or stateful.
state := &project.State{MountsDirectory: "/dev/null"}
state, err = state.WithWorkload(&spec, &scoreFile)
if err != nil {
return fmt.Errorf("failed to add score file to state: %w", err)
}

// Prime the resources with initial state and validate any issues
state, err = state.WithPrimedResources()
if err != nil {
return fmt.Errorf("failed to prime resources: %w", err)
}

// Instead of actually calling the resource provisioning system, we skip it and fill in the supported resources
// ourselves.
vars := new(compose.EnvVarTracker)
state, err = fillInLegacyResourceOutputFunctions(spec.Metadata["name"].(string), state, vars)
if err != nil {
return fmt.Errorf("failed to provision resources: %w", err)
}

workloadResourceOutputs, err := state.GetResourceOutputForWorkload(spec.Metadata["name"].(string))
if err != nil {
return fmt.Errorf("failed to gather resource outputs: %w", err)
}

// Build docker-compose configuration
//
slog.Info("Building docker-compose configuration")
proj, vars, err := compose.ConvertSpec(&spec)
proj, err := compose.ConvertSpec(&spec, workloadResourceOutputs)
if err != nil {
return fmt.Errorf("building docker-compose configuration: %w", err)
}
Expand Down Expand Up @@ -239,3 +268,28 @@ func run(cmd *cobra.Command, args []string) error {

return nil
}

func fillInLegacyResourceOutputFunctions(workloadName string, state *project.State, evt *compose.EnvVarTracker) (*project.State, error) {
out := *state
out.Resources = maps.Clone(state.Resources)
for resName, res := range state.Workloads[workloadName].Spec.Resources {
resUid := project.NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
resState := state.Resources[resUid]
if resUid.Type() == "environment" {
if resUid.Class() != "default" {
return nil, fmt.Errorf("resources '%s': '%s.%s' is not supported in score-compose", resUid, resUid.Type(), resUid.Class())
}
resState.OutputLookupFunc = evt.LookupOutput
} else if resUid.Type() == "volume" && resUid.Class() == "default" {
resState.OutputLookupFunc = func(keys ...string) (interface{}, error) {
return nil, fmt.Errorf("resource has no outputs")
}
} else {
slog.Warn(fmt.Sprintf("resources.%s: '%s.%s' is not directly supported in score-compose, references will be converted to environment variables", resName, resUid.Type(), resUid.Class()))
fake := evt.GenerateResource(resName)
resState.OutputLookupFunc = fake.LookupOutput
}
out.Resources[resUid] = resState
}
return &out, nil
}
44 changes: 12 additions & 32 deletions internal/compose/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,48 +17,28 @@ import (
compose "github.com/compose-spec/compose-go/v2/types"
score "github.com/score-spec/score-go/types"

"github.com/score-spec/score-compose/internal/project"
"github.com/score-spec/score-compose/internal/util"
)

// ConvertSpec converts SCORE specification into docker-compose configuration.
func ConvertSpec(spec *score.Workload) (*compose.Project, *EnvVarTracker, error) {
func ConvertSpec(spec *score.Workload, resources map[string]project.OutputLookupFunc) (*compose.Project, error) {
workloadName, ok := spec.Metadata["name"].(string)
if !ok || len(workloadName) == 0 {
return nil, nil, errors.New("workload metadata is missing a name")
return nil, errors.New("workload metadata is missing a name")
}

if len(spec.Containers) == 0 {
return nil, nil, errors.New("workload does not have any containers to convert into a compose service")
return nil, errors.New("workload does not have any containers to convert into a compose service")
}

var project = compose.Project{
Services: make(compose.Services),
}

// Track any uses of the environment resource or resources that are overridden with an env provider using the tracker.
envVarTracker := new(EnvVarTracker)
resources := make(map[string]ResourceWithOutputs)
// The first thing we must do is validate or create the resources this workload depends on.
// NOTE: this will soon be replaced by a much more sophisticated resource provisioning system!
for resourceName, resourceSpec := range spec.Resources {
resClass := util.DerefOr(resourceSpec.Class, "default")
if resourceSpec.Type == "environment" {
if resClass != "default" {
return nil, nil, fmt.Errorf("resources.%s: '%s.%s' is not supported in score-compose", resourceName, resourceSpec.Type, resClass)
}
resources[resourceName] = envVarTracker
} else if resourceSpec.Type == "volume" && resClass == "default" {
resources[resourceName] = resourceWithStaticOutputs{}
} else {
slog.Warn(fmt.Sprintf("resources.%s: '%s.%s' is not directly supported in score-compose, references will be converted to environment variables", resourceName, resourceSpec.Type, resClass))
// TODO: only enable this if the type.class is in an allow-list or the allow-list is '*' - otherwise return an error
resources[resourceName] = envVarTracker.GenerateResource(resourceName)
}
}

ctx, err := buildContext(spec.Metadata, resources)
if err != nil {
return nil, nil, fmt.Errorf("preparing context: %w", err)
return nil, fmt.Errorf("preparing context: %w", err)
}

var ports []compose.ServicePortConfig
Expand Down Expand Up @@ -95,7 +75,7 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, *EnvVarTracker, error)
for key, val := range cSpec.Variables {
resolved, err := ctx.Substitute(val)
if err != nil {
return nil, nil, fmt.Errorf("containers.%s.variables.%s: %w", containerName, key, err)
return nil, fmt.Errorf("containers.%s.variables.%s: %w", containerName, key, err)
}
env[key] = &resolved
}
Expand All @@ -111,21 +91,21 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, *EnvVarTracker, error)
volumes = make([]compose.ServiceVolumeConfig, len(cSpec.Volumes))
for idx, vol := range cSpec.Volumes {
if vol.Path != nil && *vol.Path != "" {
return nil, nil, fmt.Errorf("containers.%s.volumes[%d].path: can't mount named volume with sub path '%s': not supported", containerName, idx, *vol.Path)
return nil, fmt.Errorf("containers.%s.volumes[%d].path: can't mount named volume with sub path '%s': not supported", containerName, idx, *vol.Path)
}

// TODO: deprecate this - volume should be linked directly
resolvedVolumeSource, err := ctx.Substitute(vol.Source)
if err != nil {
return nil, nil, fmt.Errorf("containers.%s.volumes[%d].source: %w", containerName, idx, err)
return nil, fmt.Errorf("containers.%s.volumes[%d].source: %w", containerName, idx, err)
} else if resolvedVolumeSource != vol.Source {
slog.Warn(fmt.Sprintf("containers.%s.volumes[%d].source: interpolation will be deprecated in the future", containerName, idx))
}

if res, ok := spec.Resources[resolvedVolumeSource]; !ok {
return nil, nil, fmt.Errorf("containers.%s.volumes[%d].source: resource '%s' does not exist", containerName, idx, resolvedVolumeSource)
return nil, fmt.Errorf("containers.%s.volumes[%d].source: resource '%s' does not exist", containerName, idx, resolvedVolumeSource)
} else if res.Type != "volume" {
return nil, nil, fmt.Errorf("containers.%s.volumes[%d].source: resource '%s' is not a volume", containerName, idx, resolvedVolumeSource)
return nil, fmt.Errorf("containers.%s.volumes[%d].source: resource '%s' is not a volume", containerName, idx, resolvedVolumeSource)
}

volumes[idx] = compose.ServiceVolumeConfig{
Expand All @@ -144,7 +124,7 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, *EnvVarTracker, error)

// Files are not supported just yet
if len(cSpec.Files) > 0 {
return nil, nil, fmt.Errorf("containers.%s.files: not supported", containerName)
return nil, fmt.Errorf("containers.%s.files: not supported", containerName)
}

// Docker compose without swarm/stack mode doesn't really support resource limits. There are optimistic
Expand Down Expand Up @@ -184,5 +164,5 @@ func ConvertSpec(spec *score.Workload) (*compose.Project, *EnvVarTracker, error)
}
project.Services[svc.Name] = svc
}
return &project, envVarTracker, nil
return &project, nil
}
11 changes: 9 additions & 2 deletions internal/compose/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
score "github.com/score-spec/score-go/types"
assert "github.com/stretchr/testify/assert"

"github.com/score-spec/score-compose/internal/project"
"github.com/score-spec/score-compose/internal/util"
)

Expand Down Expand Up @@ -279,7 +280,13 @@ func TestScoreConvert(t *testing.T) {

for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
proj, vars, err := ConvertSpec(tt.Source)
evt := new(EnvVarTracker)
resourceOutputs := map[string]project.OutputLookupFunc{
"env": evt.LookupOutput,
"app-db": evt.GenerateResource("app-db").LookupOutput,
"some-dns": evt.GenerateResource("some-dns").LookupOutput,
}
proj, err := ConvertSpec(tt.Source, resourceOutputs)

if tt.Error != nil {
// On Error
Expand All @@ -290,7 +297,7 @@ func TestScoreConvert(t *testing.T) {
//
assert.NoError(t, err)
assert.Equal(t, tt.Project, proj)
assert.Equal(t, tt.Vars, vars.Accessed())
assert.Equal(t, tt.Vars, evt.Accessed())
}
})
}
Expand Down
8 changes: 5 additions & 3 deletions internal/compose/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"maps"
"regexp"
"strings"

"github.com/score-spec/score-compose/internal/project"
)

var (
Expand All @@ -23,11 +25,11 @@ var (
// templatesContext ia an utility type that provides a context for '${...}' templates substitution
type templatesContext struct {
meta resourceWithStaticOutputs
resources map[string]ResourceWithOutputs
resources map[string]project.OutputLookupFunc
}

// buildContext initializes a new templatesContext instance
func buildContext(metadata map[string]interface{}, resources map[string]ResourceWithOutputs) (*templatesContext, error) {
func buildContext(metadata map[string]interface{}, resources map[string]project.OutputLookupFunc) (*templatesContext, error) {
return &templatesContext{
meta: maps.Clone(metadata),
resources: maps.Clone(resources),
Expand Down Expand Up @@ -93,7 +95,7 @@ func (ctx *templatesContext) mapVar(ref string) (string, error) {
} else if len(parts) == 2 {
// TODO: deprecate this - this is an annoying and nonsensical legacy thing
return parts[1], nil
} else if rv2, err := rv.LookupOutput(parts[2:]...); err != nil {
} else if rv2, err := rv(parts[2:]...); err != nil {
return "", fmt.Errorf("invalid ref '%s': %w", ref, err)
} else {
resolvedValue = rv2
Expand Down
Loading

0 comments on commit ad6960d

Please sign in to comment.