Skip to content

Commit

Permalink
feat: added generate command and example provisioners
Browse files Browse the repository at this point in the history
Signed-off-by: Ben Meier <[email protected]>
  • Loading branch information
astromechza committed Mar 10, 2024
1 parent 594a3fc commit 9fe52ad
Show file tree
Hide file tree
Showing 7 changed files with 529 additions and 12 deletions.
1 change: 1 addition & 0 deletions internal/command/default.provisioners.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
280 changes: 280 additions & 0 deletions internal/command/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
package command

import (
"context"
"fmt"
"log/slog"
"os"
"slices"
"strings"

"github.com/compose-spec/compose-go/v2/types"
"github.com/imdario/mergo"
"github.com/score-spec/score-go/loader"
"github.com/score-spec/score-go/schema"
score "github.com/score-spec/score-go/types"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

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

const (
generateCmdOverridesFileFlag = "overrides-file"
generateCmdOverridePropertyFlag = "override-property"
)

var generateCommand = &cobra.Command{
Use: "generate",
Args: cobra.ArbitraryArgs,
Short: "Convert one or more Score files into a Docker compose manifest",
Long: `The generate command will convert Score files in the current Score compose project into a combined Docker compose
manifest. All resources and links between Workloads will be resolved and provisioned as required.
By default this command looks for score.yaml in the current directory, but can take explicit file names as positional
arguments.
"score-compose init" MUST be run first. An error will be thrown if the project directory is not present.
`,
Example: `
# Use default values
score-compose generate
# Specify Score files
score-compose generate score.yaml *.score.yaml
# Provide overrides when one score file is provided
score-compose generate score.yaml --override-file=./overrides.score.yaml --override-property=metadata.key=value
# Provide overrides when more than one score file is provided
score-compose generate score.yaml score-two.yaml --override-file=my-workload=./overrides.score.yaml --override-property=my-other-workload=metadata.key=value`,

// don't print the errors - we print these ourselves in main()
SilenceErrors: true,

RunE: func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true

// find the input score files
inputFiles := []string{scoreFileDefault}
if len(args) > 0 {
inputFiles = args
}
slices.Sort(inputFiles)
slog.Debug("Input Score files", "files", inputFiles)

// first load all the score files, parse them with a dummy yaml decoder to find the workload name, reject any
// with invalid or duplicate names.
workloadNames, workloadSpecs, err := loadRawScoreFiles(inputFiles)
if err != nil {
return err
}
slog.Debug("Input Workload names", "names", workloadNames)
if len(workloadNames) == 0 {
return fmt.Errorf("at least one Score file must be provided")
}

// Now read and apply any overrides files to the score files
if v, _ := cmd.Flags().GetString(generateCmdOverridesFileFlag); v != "" {
if len(workloadNames) > 1 {
return fmt.Errorf("--%s cannot be used when multiple score files are provided", generateCmdOverridesFileFlag)
}
if err := parseAndApplyOverrideFile(v, generateCmdOverridesFileFlag, workloadSpecs[workloadNames[0]]); err != nil {
return err
}
}

// Now read, parse, and apply any override properties to the score files
if v, _ := cmd.Flags().GetStringArray(generateCmdOverridePropertyFlag); len(v) > 0 {
for _, overridePropertyEntry := range v {
if err := parseAndApplyOverrideProperty(overridePropertyEntry, generateCmdOverridesFileFlag, workloadSpecs[workloadNames[0]]); err != nil {
return err
}
}
}

sd, ok, err := project.LoadStateDirectory(".")
if err != nil {
return fmt.Errorf("failed to load existing state directory: %w", err)
} else if !ok {
return fmt.Errorf("state directory does not exist, please run \"score-compose init\" first")
}
slog.Info(fmt.Sprintf("Loaded state directory with docker compose project '%s'", sd.State.ComposeProjectName))
currentState := &sd.State

// Now validate with score spec
for workloadName, spec := range workloadSpecs {
// Ensure transforms are applied (be a good citizen)
if changes, err := schema.ApplyCommonUpgradeTransforms(spec); err != nil {
return fmt.Errorf("failed to upgrade spec: %w", err)
} else if len(changes) > 0 {
for _, change := range changes {
slog.Info(fmt.Sprintf("Applying backwards compatible upgrade to '%s': %s", workloadName, change))
}
}
if err := schema.Validate(spec); err != nil {
return fmt.Errorf("validation errors in workload '%s': %w", workloadName, err)
}
slog.Info(fmt.Sprintf("Validated workload '%s'", workloadName))

var out score.Workload
if err := loader.MapSpec(&out, spec); err != nil {
return fmt.Errorf("failed to convert '%s' to structure: %w", workloadName, err)
}

currentState, err = currentState.WithWorkload(&out, nil)
if err != nil {
return fmt.Errorf("failed to add workload '%s': %w", workloadName, err)
}
}

loadedProvisioners, err := provloader.LoadProvisionersFromDirectory(sd.Path, provloader.DefaultSuffix)
if err != nil {
return fmt.Errorf("failed to load provisioners: %w", err)
} else if len(loadedProvisioners) > 0 {
slog.Info(fmt.Sprintf("Successfully loaded %d resource provisioners", len(loadedProvisioners)))
}

currentState, err = currentState.WithPrimedResources()
if err != nil {
return fmt.Errorf("failed to prime resources: %w", err)
}

superProject := &types.Project{
Name: sd.State.ComposeProjectName,
Services: make(types.Services, 0),
Volumes: map[string]types.VolumeConfig{},
Networks: map[string]types.NetworkConfig{},
}

currentState, err = provisioners.ProvisionResources(context.Background(), currentState, loadedProvisioners, superProject)
if err != nil {
return fmt.Errorf("failed to provision: %w", err)
} else if len(currentState.Resources) > 0 {
slog.Info(fmt.Sprintf("Provisioned %d resources", len(currentState.Resources)))
}

for _, workloadName := range workloadNames {
outputFunctions, err := currentState.GetResourceOutputForWorkload(workloadName)
if err != nil {
return err
}

slog.Info(fmt.Sprintf("Converting workload '%s' to Docker compose", workloadName))
spec := currentState.Workloads[workloadName].Spec
converted, err := compose.ConvertSpec(&spec, outputFunctions)
if err != nil {
return fmt.Errorf("failed to convert workload '%s' to Docker compose: %w", workloadName, err)
}

for serviceName, service := range converted.Services {
if _, ok := superProject.Services[serviceName]; ok {
return fmt.Errorf("failed to add converted workload '%s': duplicate service name '%s'", workloadName, serviceName)
}
superProject.Services[serviceName] = service
}
for volumeName, volume := range converted.Volumes {
if _, ok := superProject.Volumes[volumeName]; ok {
return fmt.Errorf("failed to add converted workload '%s': duplicate volume name '%s'", workloadName, volumeName)
}
superProject.Volumes[volumeName] = volume
}
for networkName, network := range converted.Networks {
if _, ok := superProject.Networks[networkName]; ok {
return fmt.Errorf("failed to add converted workload '%s': duplicated network name '%s'", workloadName, networkName)
}
superProject.Networks[networkName] = network
}
}

raw, _ := yaml.Marshal(superProject)

v, _ := cmd.Flags().GetString("output")
if v == "" {
return fmt.Errorf("no output file specified")
} else if v == "-" {
_, _ = fmt.Fprint(cmd.OutOrStdout(), string(raw))
} else if err := os.WriteFile(v+".temp", raw, 0755); err != nil {
return fmt.Errorf("failed to write output file: %w", err)
} else if err := os.Rename(v+".temp", v); err != nil {
return fmt.Errorf("failed to complete writing output file: %w", err)
}
return nil
},
}

// loadRawScoreFiles loads raw score specs as yaml from the given files and finds all the workload names. It throws
// errors if it failed to read, load, or if names are duplicated.
func loadRawScoreFiles(fileNames []string) ([]string, map[string]map[string]interface{}, error) {
workloadNames := make([]string, 0, len(fileNames))
workloadToRawScore := make(map[string]map[string]interface{}, len(fileNames))

for _, fileName := range fileNames {
var out map[string]interface{}
raw, err := os.ReadFile(fileName)
if err != nil {
return nil, nil, fmt.Errorf("failed to read '%s': %w", fileName, err)
} else if err := yaml.Unmarshal(raw, &out); err != nil {
return nil, nil, fmt.Errorf("failed to decode '%s' as yaml: %w", fileName, err)
}

var workloadName string
if meta, ok := out["metadata"].(map[string]interface{}); ok {
workloadName, _ = meta["name"].(string)
if _, ok := workloadToRawScore[workloadName]; ok {
return nil, nil, fmt.Errorf("workload name '%s' in file '%s' is used more than once", workloadName, fileName)
}
}
workloadNames = append(workloadNames, workloadName)
workloadToRawScore[workloadName] = out
}
return workloadNames, workloadToRawScore, nil
}

func init() {
generateCommand.Flags().StringP("output", "o", "compose.yaml", "The output file to write the composed compose file to")
generateCommand.Flags().String(generateCmdOverridesFileFlag, "", "An optional file of Score overrides to merge in")
generateCommand.Flags().StringArray(generateCmdOverridePropertyFlag, []string{}, "An optional set of path=key overrides to set or remove")
rootCmd.AddCommand(generateCommand)
}

func parseAndApplyOverrideFile(entry string, flagName string, spec map[string]interface{}) error {
if raw, err := os.ReadFile(entry); err != nil {
return fmt.Errorf("--%s '%s' is invalid, failed to read file: %w", flagName, entry, err)
} else {
slog.Info(fmt.Sprintf("Applying overrides from %s to workload", entry))
var out map[string]interface{}
if err := yaml.Unmarshal(raw, &out); err != nil {
return fmt.Errorf("--%s '%s' is invalid: failed to decode yaml: %w", flagName, entry, err)
} else if err := mergo.Merge(&spec, out, mergo.WithOverride); err != nil {
return fmt.Errorf("--%s '%s' failed to apply: %w", flagName, entry, err)
}
}
return nil
}

func parseAndApplyOverrideProperty(entry string, flagName string, spec map[string]interface{}) error {
parts := strings.SplitN(entry, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("--%s '%s' is invalid, expected a =-separated path and value", flagName, entry)
}
if parts[1] == "" {
slog.Info(fmt.Sprintf("Overriding '%s' in workload", parts[0]))
if err := writePathInStruct(spec, parseDotPathParts(parts[0]), true, nil); err != nil {
return fmt.Errorf("--%s '%s' could not be applied: %w", flagName, entry, err)
}
} else {
var value interface{}
if err := yaml.Unmarshal([]byte(parts[1]), &value); err != nil {
return fmt.Errorf("--%s '%s' is invalid, failed to unmarshal value as json: %w", flagName, entry, err)
}
slog.Info(fmt.Sprintf("Overriding '%s' in workload", parts[0]))
if err := writePathInStruct(spec, parseDotPathParts(parts[0]), false, value); err != nil {
return fmt.Errorf("--%s '%s' could not be applied: %w", flagName, entry, err)
}
}
return nil
}
20 changes: 19 additions & 1 deletion internal/command/init.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package command

import (
_ "embed"
"errors"
"fmt"
"log/slog"
Expand All @@ -11,6 +12,7 @@ import (
"github.com/spf13/cobra"

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

const DefaultScoreFileContent = `# Score provides a developer-centric and platform-agnostic
Expand Down Expand Up @@ -50,6 +52,9 @@ service:
resources: {}
`

//go:embed default.provisioners.yaml
var defaultProvisionersContent string

var initCmd = &cobra.Command{
Use: "init",
Args: cobra.NoArgs,
Expand Down Expand Up @@ -103,7 +108,7 @@ acts as a namespace when multiple score files and containers are used.

slog.Info(fmt.Sprintf("Writing new state directory '%s'", project.DefaultRelativeStateDirectory))
wd, _ := os.Getwd()
sd := &project.StateDirectory{
sd = &project.StateDirectory{
Path: project.DefaultRelativeStateDirectory,
State: project.State{
Workloads: map[string]project.ScoreWorkloadState{},
Expand All @@ -120,6 +125,12 @@ acts as a namespace when multiple score files and containers are used.
if err := sd.Persist(); err != nil {
return fmt.Errorf("failed to persist new compose project name: %w", err)
}

dst := "default" + loader.DefaultSuffix
slog.Info(fmt.Sprintf("Writing default provisioners yaml file '%s'", dst))
if err := os.WriteFile(filepath.Join(sd.Path, dst), []byte(defaultProvisionersContent), 0644); err != nil {
return fmt.Errorf("failed to write provisioners: %w", err)
}
}

if _, err := os.ReadFile(initCmdScoreFile); err != nil {
Expand All @@ -135,6 +146,13 @@ acts as a namespace when multiple score files and containers are used.
} else {
slog.Info(fmt.Sprintf("Found existing Score file '%s'", initCmdScoreFile))
}

if provs, err := loader.LoadProvisionersFromDirectory(sd.Path, loader.DefaultSuffix); err != nil {
return fmt.Errorf("failed to load existing provisioners: %w", err)
} else {
slog.Debug(fmt.Sprintf("Successfully loaded %d resource provisioners", len(provs)))
}

slog.Info(fmt.Sprintf("Read more about the Score specification at https://docs.score.dev/docs/"))

return nil
Expand Down
Loading

0 comments on commit 9fe52ad

Please sign in to comment.