diff --git a/.vscode/launch.json b/.vscode/launch.json index 97cbcae..fc91f12 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "mode": "debug", "program": "${workspaceFolder}/src", - "args": ["run"], + "args": ["run", "prod", "go"], "cwd": "${workspaceFolder}" } ] diff --git a/go.mod b/go.mod index 3582cbc..822254d 100644 --- a/go.mod +++ b/go.mod @@ -12,5 +12,5 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cobra v1.1.3 github.com/stretchr/testify v1.7.0 - gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/go.sum b/go.sum index 145875e..4dffd1f 100644 --- a/go.sum +++ b/go.sum @@ -354,8 +354,9 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/src/cmd/get/context/context.go b/src/cmd/get/context/context.go index 48ae066..2071345 100644 --- a/src/cmd/get/context/context.go +++ b/src/cmd/get/context/context.go @@ -13,34 +13,27 @@ func New() *cobra.Command { return &cobra.Command{ Use: "context", Short: "Displays resolved values of given context", - Args: cobra.RangeArgs(0, 1), + Args: cobra.ArbitraryArgs, RunE: func(_ *cobra.Command, args []string) error { - name := "" - if len(args) == 1 { - name = args[0] - } - return run(name) + return run(args) }, } } -func run(name string) error { +func run(names []string) error { contexts, err := yey.LoadContexts() if err != nil { return err } - if name == "" { - var err error - name, err = cmd.PromptContext(contexts) - if err != nil { - return fmt.Errorf("failed to prompt for desired context: %w", err) - } + names, err = cmd.GetOrPromptContextNames(contexts, names) + if err != nil { + return err } - context, err := contexts.GetContext(name) + context, err := contexts.GetContext(names) if err != nil { - return fmt.Errorf("failed to get context with name %q: %w", name, err) + return fmt.Errorf("failed to get context: %w", err) } fmt.Println(context.String()) diff --git a/src/cmd/get/contexts/contexts.go b/src/cmd/get/contexts/contexts.go index 0a1f4f5..b51732d 100644 --- a/src/cmd/get/contexts/contexts.go +++ b/src/cmd/get/contexts/contexts.go @@ -27,6 +27,9 @@ func run() error { return err } - fmt.Println(strings.Join(contexts.GetNames(), "\n")) + names := contexts.GetNamesInAllLayers() + for i, layerNames := range names { + fmt.Printf("%s: %s\n", contexts.Layers[i].Name, strings.Join(layerNames, ", ")) + } return nil } diff --git a/src/cmd/prompts.go b/src/cmd/prompts.go index e087491..c3570ff 100644 --- a/src/cmd/prompts.go +++ b/src/cmd/prompts.go @@ -7,28 +7,22 @@ import ( yey "github.com/silphid/yey/src/internal" ) -func PromptContext(contexts yey.Contexts) (string, error) { - // Get context names - names := contexts.GetNames() - if len(names) == 0 { - return "", fmt.Errorf("no context defined") - } - - // Only one context defined, no need to prompt - if len(names) == 1 { - return names[0], nil - } - - // Show selection prompt - prompt := &survey.Select{ - Message: "Select context:", - Options: names, - } +// Parses given value into context name and variant and, as needed, prompt user for those values +func GetOrPromptContextNames(contexts yey.Contexts, names []string) ([]string, error) { + availableNames := contexts.GetNamesInAllLayers() - selectedIndex := 0 - if err := survey.AskOne(prompt, &selectedIndex); err != nil { - return "", err + // Prompt unspecified names + for i := len(names); i < len(contexts.Layers); i++ { + prompt := &survey.Select{ + Message: fmt.Sprintf("Select %s:", contexts.Layers[i].Name), + Options: availableNames[i], + } + selectedIndex := 0 + if err := survey.AskOne(prompt, &selectedIndex); err != nil { + return nil, err + } + names = append(names, availableNames[i][selectedIndex]) } - return names[selectedIndex], nil + return names, nil } diff --git a/src/cmd/remove/remove.go b/src/cmd/remove/remove.go index 2b5aa1d..43711b0 100644 --- a/src/cmd/remove/remove.go +++ b/src/cmd/remove/remove.go @@ -19,13 +19,9 @@ func New() *cobra.Command { Use: "remove", Aliases: []string{"rm"}, Short: "Removes context container", - Args: cobra.RangeArgs(0, 1), + Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { - name := "" - if len(args) == 1 { - name = args[0] - } - return run(cmd.Context(), name, options) + return run(cmd.Context(), args, options) }, } @@ -38,23 +34,20 @@ type options struct { force bool } -func run(ctx context.Context, name string, options options) error { +func run(ctx context.Context, names []string, options options) error { contexts, err := yey.LoadContexts() if err != nil { return err } - if name == "" { - var err error - name, err = cmd.PromptContext(contexts) - if err != nil { - return fmt.Errorf("failed to prompt for desired context: %w", err) - } + names, err = cmd.GetOrPromptContextNames(contexts, names) + if err != nil { + return fmt.Errorf("failed to prompt for context: %w", err) } - context, err := contexts.GetContext(name) + context, err := contexts.GetContext(names) if err != nil { - return fmt.Errorf("failed to get context with name %q: %w", name, err) + return fmt.Errorf("failed to get context: %w", err) } container := yey.ContainerName(contexts.Path, context) diff --git a/src/cmd/run/run.go b/src/cmd/run/run.go index f740788..8e9b451 100644 --- a/src/cmd/run/run.go +++ b/src/cmd/run/run.go @@ -22,16 +22,12 @@ func New() *cobra.Command { cmd := &cobra.Command{ Use: "run", Short: "Runs container using given context", - Args: cobra.RangeArgs(0, 1), + Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { - name := "" - if len(args) == 1 { - name = args[0] - } if !cmd.Flag("rm").Changed { options.Remove = nil } - return run(cmd.Context(), name, options) + return run(cmd.Context(), args, options) }, } @@ -46,23 +42,20 @@ type Options struct { Reset bool } -func run(ctx context.Context, name string, options Options) error { +func run(ctx context.Context, names []string, options Options) error { contexts, err := yey.LoadContexts() if err != nil { return err } - if name == "" { - var err error - name, err = cmd.PromptContext(contexts) - if err != nil { - return fmt.Errorf("failed to prompt for desired context: %w", err) - } + names, err = cmd.GetOrPromptContextNames(contexts, names) + if err != nil { + return err } - yeyContext, err := contexts.GetContext(name) + yeyContext, err := contexts.GetContext(names) if err != nil { - return fmt.Errorf("failed to get context with name %q: %w", name, err) + return fmt.Errorf("failed to get context: %w", err) } if options.Remove != nil { yeyContext.Remove = options.Remove diff --git a/src/cmd/tidy/tidy.go b/src/cmd/tidy/tidy.go index 7d5758b..cb1115c 100644 --- a/src/cmd/tidy/tidy.go +++ b/src/cmd/tidy/tidy.go @@ -39,13 +39,14 @@ func run(ctx context.Context, options options) error { } validNames := make(map[string]struct{}) - for _, name := range contexts.GetNames() { - ctx, err := contexts.GetContext(name) + forEachPossibleNameCombination(contexts.GetNamesInAllLayers(), nil, func(names []string) error { + ctx, err := contexts.GetContext(names) if err != nil { return err } validNames[yey.ContainerName(contexts.Path, ctx)] = struct{}{} - } + return nil + }) prefix := yey.ContainerPathPrefix(contexts.Path) @@ -67,3 +68,19 @@ func run(ctx context.Context, options options) error { return docker.RemoveMany(ctx, unreferencedContainers, docker.WithForceRemove(options.force)) } + +// forEachPossibleNameCombination calls given callback function with each possible combination of names from all layers +func forEachPossibleNameCombination(namesInLayers [][]string, baseCombo []string, fn func([]string) error) error { + currentDepth := len(baseCombo) + for _, name := range namesInLayers[currentDepth] { + currentCombo := append(baseCombo, name) + if currentDepth == len(namesInLayers)-1 { + fn(currentCombo) + } else { + if err := forEachPossibleNameCombination(namesInLayers, currentCombo, fn); err != nil { + return err + } + } + } + return nil +} diff --git a/src/cmd/tidy/tidy_test.go b/src/cmd/tidy/tidy_test.go new file mode 100644 index 0000000..07e0279 --- /dev/null +++ b/src/cmd/tidy/tidy_test.go @@ -0,0 +1,29 @@ +package tidy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestForEachPossibleNameCombination(t *testing.T) { + allNames := [][]string{ + {"dev", "stg", "prod"}, + {"go", "node"}, + } + expected := [][]string{ + {"dev", "go"}, + {"dev", "node"}, + {"stg", "go"}, + {"stg", "node"}, + {"prod", "go"}, + {"prod", "node"}, + } + var actual [][]string + forEachPossibleNameCombination(allNames, nil, func(combo []string) error { + actual = append(actual, combo) + return nil + }) + + assert.Equal(t, expected, actual) +} diff --git a/src/internal/context.go b/src/internal/context.go index c8bc401..956466c 100644 --- a/src/internal/context.go +++ b/src/internal/context.go @@ -1,7 +1,7 @@ package yey import ( - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type DockerBuild struct { diff --git a/src/internal/contextFile.go b/src/internal/contextFile.go index 103fe46..3be8fe9 100644 --- a/src/internal/contextFile.go +++ b/src/internal/contextFile.go @@ -10,7 +10,7 @@ import ( "strings" "github.com/mitchellh/go-homedir" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) const ( @@ -19,9 +19,11 @@ const ( // ContextFile represents yey's current config persisted to disk type ContextFile struct { - Version int - Parent string - Contexts `yaml:",inline"` + Version int + Parent string + Path string `yaml:"-"` + Context `yaml:",inline"` + Layers Layers `yaml:"layers"` } // readContextFileFromWorkingDirectory scans the current directory and searches for a .yeyrc.yaml file and returns @@ -86,7 +88,7 @@ func parseContextFile(dir string, data []byte) (Contexts, error) { contexts := Contexts{ Context: ctxFile.Context, - Named: ctxFile.Named, + Layers: ctxFile.Layers, } if dir != "" { @@ -152,10 +154,12 @@ func resolveContextsPaths(dir string, contexts Contexts) (Contexts, error) { if err != nil { return Contexts{}, err } - for name, context := range contexts.Named { - contexts.Named[name], err = resolveContextPaths(dir, context) - if err != nil { - return Contexts{}, err + for _, layer := range contexts.Layers { + for name, context := range layer.Contexts { + layer.Contexts[name], err = resolveContextPaths(dir, context) + if err != nil { + return Contexts{}, err + } } } return contexts, nil diff --git a/src/internal/contexts.go b/src/internal/contexts.go index f55a6c0..4a4cc5a 100644 --- a/src/internal/contexts.go +++ b/src/internal/contexts.go @@ -5,74 +5,74 @@ import ( "sort" ) -const ( - BaseContextName = "base" -) - // Contexts represents a combinaison of base and named contexts type Contexts struct { - Path string `yaml:"-"` - Context `yaml:",inline"` - Named map[string]Context + Path string + Context + Layers Layers } // Merge creates a deep-copy of this object and copies values from given source object on top of it func (c Contexts) Merge(source Contexts) Contexts { - merged := Contexts{ + return Contexts{ Context: c.Context.Merge(source.Context), - Named: make(map[string]Context), - } - for key, value := range c.Named { - merged.Named[key] = value.Clone() - } - for key, value := range source.Named { - existing, ok := merged.Named[key] - if ok { - merged.Named[key] = existing.Merge(value) - } else { - merged.Named[key] = value - } + Layers: c.Layers.Merge(source.Layers), } - return merged } -// GetNames returns the list of all context names user can choose from, -// including the special "base" contexts. -func (c Contexts) GetNames() []string { - // Extract unique names - namesMap := make(map[string]bool) - for name := range c.Named { - namesMap[name] = true - } +// GetNamesInAllLayers returns the list of all context names user can choose from, +func (c Contexts) GetNamesInAllLayers() [][]string { + names := make([][]string, 0, len(c.Layers)) - // Sort - sortedNames := make([]string, 0, len(namesMap)) - for name := range namesMap { - sortedNames = append(sortedNames, name) - } - sort.Strings(sortedNames) + for _, layer := range c.Layers { + // Extract unique names + namesMap := make(map[string]bool) + for name := range layer.Contexts { + namesMap[name] = true + } + + // Sort + sortedNames := make([]string, 0, len(namesMap)) + for name := range namesMap { + sortedNames = append(sortedNames, name) + } + sort.Strings(sortedNames) - // Prepend special contexts - names := make([]string, 0, len(sortedNames)+1) - names = append(names, BaseContextName) - names = append(names, sortedNames...) + names = append(names, sortedNames) + } return names } -// GetContext returns context with given name, or base context -// if name is "base". -func (c Contexts) GetContext(name string) (Context, error) { - base := c.Context - if name == BaseContextName { - base.Name = "base" - return base, nil +// GetContext returns context with given name (or base context, if name is "base") and +// variant (or no variant, if variant name is "") +func (c Contexts) GetContext(names []string) (Context, error) { + if len(names) != len(c.Layers) { + return Context{}, fmt.Errorf("number of context names (%d) does not match number of layers (%d)", len(names), len(c.Layers)) } - named, ok := c.Named[name] - if !ok { - return Context{}, fmt.Errorf("context %q not found", name) + + // Start with base context + ctx := c.Context + compositeName := "" + + for i, layer := range c.Layers { + name := names[i] + + // Merge layer context + layerContext, ok := layer.Contexts[name] + if !ok { + return Context{}, fmt.Errorf("context %q not found in layer %q", name, layer.Name) + } + ctx = ctx.Merge(layerContext) + + // Accumulate composite name + if compositeName == "" { + compositeName = name + } else { + compositeName = fmt.Sprintf("%s %s", compositeName, name) + } } - merged := base.Merge(named) - merged.Name = name - return merged, nil + + ctx.Name = compositeName + return ctx, nil } diff --git a/src/internal/contexts_test.go b/src/internal/contexts_test.go index d82fabf..7264cda 100644 --- a/src/internal/contexts_test.go +++ b/src/internal/contexts_test.go @@ -10,9 +10,14 @@ import ( func loadContexts(baseFile, ctx1Key, ctx1File, ctx2Key, ctx2File string) Contexts { return Contexts{ Context: loadContext(baseFile), - Named: map[string]Context{ - ctx1Key: loadContext(ctx1File), - ctx2Key: loadContext(ctx2File), + Layers: Layers{ + Layer{ + Name: "layerName", + Contexts: map[string]Context{ + ctx1Key: loadContext(ctx1File), + ctx2Key: loadContext(ctx2File), + }, + }, }, } } @@ -41,20 +46,16 @@ func TestGetContext(t *testing.T) { name: "ctx3", expected: "base1_base1b_ctx3", }, - { - name: "base", - expected: "base1_base1b", - }, { name: "unknown", - error: `context "unknown" not found`, + error: `context "unknown" not found in layer "layerName"`, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - actual, err := merged.GetContext(c.name) + actual, err := merged.GetContext([]string{c.name}) actual.Name = c.name if c.error != "" { diff --git a/src/internal/layer.go b/src/internal/layer.go new file mode 100644 index 0000000..45e7d7c --- /dev/null +++ b/src/internal/layer.go @@ -0,0 +1,36 @@ +package yey + +type Layer struct { + Name string + Contexts map[string]Context +} + +// Clone returns a deep-copy of this layer +func (l Layer) Clone() Layer { + clone := l + clone.Contexts = make(map[string]Context, len(l.Contexts)) + for key, value := range l.Contexts { + clone.Contexts[key] = value.Clone() + } + return clone +} + +// Merge creates a deep-copy of this layer and copies values from given source layer on top of it +func (l Layer) Merge(source Layer) Layer { + merged := Layer{ + Name: l.Name, + Contexts: make(map[string]Context), + } + for key, value := range l.Contexts { + merged.Contexts[key] = value.Clone() + } + for key, value := range source.Contexts { + existing, ok := merged.Contexts[key] + if ok { + merged.Contexts[key] = existing.Merge(value) + } else { + merged.Contexts[key] = value + } + } + return merged +} diff --git a/src/internal/layers.go b/src/internal/layers.go new file mode 100644 index 0000000..9ac7869 --- /dev/null +++ b/src/internal/layers.go @@ -0,0 +1,65 @@ +package yey + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +type Layers []Layer + +// Clone returns a deep-copy of this layer +func (l Layers) Clone() Layers { + clone := Layers{} + for _, layer := range l { + clone = append(clone, layer.Clone()) + } + return clone +} + +// GetByName returns layer with given name and whether it was found +func (l Layers) GetByName(name string) (Layer, bool) { + for _, layer := range l { + if layer.Name == name { + return layer, true + } + } + return Layer{}, false +} + +// Merge creates a deep-copy of this layer and copies values from given source layer on top of it +func (l Layers) Merge(source Layers) Layers { + merged := l.Clone() + for i, layer := range source { + existing, ok := merged.GetByName(layer.Name) + if ok { + merged[i] = existing.Merge(layer) + } else { + merged = append(merged, layer.Clone()) + } + } + return merged +} + +func (layers *Layers) UnmarshalYAML(n *yaml.Node) error { + if n.Kind != yaml.MappingNode { + return fmt.Errorf(`expecting map for "layers" property at line %d, column %d`, n.Line, n.Column) + } + for i := 0; i < len(n.Content); i += 2 { + layerName := n.Content[i].Value + layerMap := n.Content[i+1] + if layerMap.Kind != yaml.MappingNode { + return fmt.Errorf(`expecting map for layer item at line %d, column %d`, n.Line, n.Column) + } + var contexts map[string]Context + if err := layerMap.Decode(&contexts); err != nil { + return fmt.Errorf("failed to parse contexts for layer %q: %w", layerName, err) + } + layer := Layer{ + Name: layerName, + Contexts: contexts, + } + *layers = append(*layers, layer) + } + return nil +} diff --git a/src/internal/yey.go b/src/internal/yey.go index 42f4da8..5455e77 100644 --- a/src/internal/yey.go +++ b/src/internal/yey.go @@ -27,11 +27,15 @@ func ContainerName(path string, context Context) string { return fmt.Sprintf( "%s-%s-%s", ContainerPathPrefix(path), - context.Name, + sanitizeContextName(context.Name), hash(context.String()), ) } +func sanitizeContextName(value string) string { + return special.ReplaceAllString(value, "-") +} + func sanitizePathName(value string) string { value = spaces.ReplaceAllString(value, "_") value = special.ReplaceAllString(value, "")