From 6a8ace5c657e27ae40f9511671e6a8ec78f7112a Mon Sep 17 00:00:00 2001 From: fabriziopandini Date: Fri, 12 Oct 2018 17:55:10 +0200 Subject: [PATCH] add phase runner --- cmd/kubeadm/app/cmd/phases/BUILD | 1 + cmd/kubeadm/app/cmd/phases/workflow/BUILD | 39 ++ cmd/kubeadm/app/cmd/phases/workflow/doc.go | 47 +++ .../app/cmd/phases/workflow/doc_test.go | 109 +++++ cmd/kubeadm/app/cmd/phases/workflow/phase.go | 57 +++ cmd/kubeadm/app/cmd/phases/workflow/runner.go | 382 ++++++++++++++++++ .../app/cmd/phases/workflow/runner_test.go | 279 +++++++++++++ 7 files changed, 914 insertions(+) create mode 100644 cmd/kubeadm/app/cmd/phases/workflow/BUILD create mode 100644 cmd/kubeadm/app/cmd/phases/workflow/doc.go create mode 100644 cmd/kubeadm/app/cmd/phases/workflow/doc_test.go create mode 100644 cmd/kubeadm/app/cmd/phases/workflow/phase.go create mode 100644 cmd/kubeadm/app/cmd/phases/workflow/runner.go create mode 100644 cmd/kubeadm/app/cmd/phases/workflow/runner_test.go diff --git a/cmd/kubeadm/app/cmd/phases/BUILD b/cmd/kubeadm/app/cmd/phases/BUILD index 0c35aa4fd3..6bb994a840 100644 --- a/cmd/kubeadm/app/cmd/phases/BUILD +++ b/cmd/kubeadm/app/cmd/phases/BUILD @@ -97,6 +97,7 @@ filegroup( srcs = [ ":package-srcs", "//cmd/kubeadm/app/cmd/phases/certs:all-srcs", + "//cmd/kubeadm/app/cmd/phases/workflow:all-srcs", ], tags = ["automanaged"], visibility = ["//visibility:public"], diff --git a/cmd/kubeadm/app/cmd/phases/workflow/BUILD b/cmd/kubeadm/app/cmd/phases/workflow/BUILD new file mode 100644 index 0000000000..772ff0c66c --- /dev/null +++ b/cmd/kubeadm/app/cmd/phases/workflow/BUILD @@ -0,0 +1,39 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "doc.go", + "phase.go", + "runner.go", + ], + importpath = "k8s.io/kubernetes/cmd/kubeadm/app/cmd/phases/workflow", + visibility = ["//visibility:public"], + deps = [ + "//vendor/github.com/spf13/cobra:go_default_library", + "//vendor/github.com/spf13/pflag:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = [ + "doc_test.go", + "runner_test.go", + ], + embed = [":go_default_library"], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) diff --git a/cmd/kubeadm/app/cmd/phases/workflow/doc.go b/cmd/kubeadm/app/cmd/phases/workflow/doc.go new file mode 100644 index 0000000000..e5356047e0 --- /dev/null +++ b/cmd/kubeadm/app/cmd/phases/workflow/doc.go @@ -0,0 +1,47 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package workflow implements a workflow manager to be used for +implementing composable kubeadm workflows. + +Composable kubeadm workflows are built by an ordered sequence of phases; +each phase can have it's own, nested, ordered sequence of sub phases. +For instance + + preflight Run master pre-flight checks + certs Generates all PKI assets necessary to establish the control plane + /ca Generates a self-signed kubernetes CA to provision identities for Kubernetes components + /apiserver Generates an API server serving certificate and key + ... + kubeconfig Generates all kubeconfig files necessary to establish the control plane + /admin Generates a kubeconfig file for the admin to use and for kubeadm itself + /kubelet Generates a kubeconfig file for the kubelet to use. + ... + ... + +Phases are designed to be reusable across different kubeadm workflows thus allowing +e.g. reuse of phase certs in both kubeadm init and kubeadm join --control-plane workflows. + +Each workflow can be defined and managed using a Runner, that will run all +the phases according to the given order; nested phases will be executed immediately +after their parent phase. + +The Runner behavior can be changed by setting the RunnerOptions, typically +exposed as kubeadm command line flags, thus allowing to filter the list of phases +to be executed. +*/ +package workflow diff --git a/cmd/kubeadm/app/cmd/phases/workflow/doc_test.go b/cmd/kubeadm/app/cmd/phases/workflow/doc_test.go new file mode 100644 index 0000000000..b97abe6123 --- /dev/null +++ b/cmd/kubeadm/app/cmd/phases/workflow/doc_test.go @@ -0,0 +1,109 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "errors" + "fmt" +) + +var myWorkflowRunner = NewRunner() + +type myWorkflowData struct { + data string +} + +func (c *myWorkflowData) Data() string { + return c.data +} + +type myPhaseData interface { + Data() string +} + +func ExamplePhase() { + // Create a phase + var myPhase1 = Phase{ + Name: "myPhase1", + Short: "A phase of a kubeadm composable workflow...", + Run: func(data RunData) error { + // transform data into a typed data struct + d, ok := data.(myPhaseData) + if !ok { + return errors.New("invalid RunData type") + } + + // implement your phase logic... + fmt.Printf("%v", d.Data()) + return nil + }, + } + + // Create another phase + var myPhase2 = Phase{ + Name: "myPhase2", + Short: "Another phase of a kubeadm composable workflow...", + Run: func(data RunData) error { + // transform data into a typed data struct + d, ok := data.(myPhaseData) + if !ok { + return errors.New("invalid RunData type") + } + + // implement your phase logic... + fmt.Printf("%v", d.Data()) + return nil + }, + } + + // Adds the new phases to the workflow + // Phases will be executed the same order they are added to the workflow + myWorkflowRunner.AppendPhase(myPhase1) + myWorkflowRunner.AppendPhase(myPhase2) +} + +func ExampleRunner_Run() { + // Create a phase + var myPhase = Phase{ + Name: "myPhase", + Short: "A phase of a kubeadm composable workflow...", + Run: func(data RunData) error { + // transform data into a typed data struct + d, ok := data.(myPhaseData) + if !ok { + return errors.New("invalid RunData type") + } + + // implement your phase logic... + fmt.Printf("%v", d.Data()) + return nil + }, + } + + // Adds the new phase to the workflow + var myWorkflowRunner = NewRunner() + myWorkflowRunner.AppendPhase(myPhase) + + // Defines the method that creates the runtime data shared + // among all the phases included in the workflow + myWorkflowRunner.SetDataInitializer(func() (RunData, error) { + return myWorkflowData{data: "some data"}, nil + }) + + // Runs the workflow + myWorkflowRunner.Run() +} diff --git a/cmd/kubeadm/app/cmd/phases/workflow/phase.go b/cmd/kubeadm/app/cmd/phases/workflow/phase.go new file mode 100644 index 0000000000..be4bf56c66 --- /dev/null +++ b/cmd/kubeadm/app/cmd/phases/workflow/phase.go @@ -0,0 +1,57 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +// Phase provides an implementation of a workflow phase that allows +// creation of new phases by simply instantiating a variable of this type. +type Phase struct { + // name of the phase. + // Phase name should be unique among peer phases (phases belonging to + // the same workflow or phases belonging to the same parent phase). + Name string + + // Short description of the phase. + Short string + + // Long returns the long description of the phase. + Long string + + // Example returns the example for the phase. + Example string + + // Hidden define if the phase should be hidden in the workflow help. + // e.g. PrintFilesIfDryRunning phase in the kubeadm init workflow is candidate for being hidden to the users + Hidden bool + + // Phases defines a nested, ordered sequence of phases. + Phases []Phase + + // Run defines a function implementing the phase action. + // It is recommended to implent type assertion, e.g. using golang type switch, + // for validating the RunData type. + Run func(data RunData) error + + // RunIf define a function that implements a condition that should be checked + // before executing the phase action. + // If this function return nil, the phase action is always executed. + RunIf func(data RunData) (bool, error) +} + +// AppendPhase adds the given phase to the nested, ordered sequence of phases. +func (t *Phase) AppendPhase(phase Phase) { + t.Phases = append(t.Phases, phase) +} diff --git a/cmd/kubeadm/app/cmd/phases/workflow/runner.go b/cmd/kubeadm/app/cmd/phases/workflow/runner.go new file mode 100644 index 0000000000..2f1051ee81 --- /dev/null +++ b/cmd/kubeadm/app/cmd/phases/workflow/runner.go @@ -0,0 +1,382 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// phaseSeparator defines the separator to be used when concatenating nested +// phase names +const phaseSeparator = "/" + +// RunnerOptions defines the options supported during the execution of a +// kubeadm composable workflows +type RunnerOptions struct { + // FilterPhases defines the list of phases to be executed (if empty, all). + FilterPhases []string + + // SkipPhases defines the list of phases to be excluded by execution (if empty, none). + SkipPhases []string +} + +// RunData defines the data shared among all the phases included in the workflow, that is any type. +type RunData = interface{} + +// Runner implements management of composable kubeadm workflows. +type Runner struct { + // Options that regulate the runner behavior. + Options RunnerOptions + + // Phases composing the workflow to be managed by the runner. + Phases []Phase + + // runDataInitializer defines a function that creates the runtime data shared + // among all the phases included in the workflow + runDataInitializer func() (RunData, error) + + // runData is part of the internal state of the runner and it is used for implementing + // a singleton in the InitData methods (thus avoiding to initialize data + // more than one time) + runData RunData + + // phaseRunners is part of the internal state of the runner and provides + // a list of wrappers to phases composing the workflow with contextual + // information supporting phase execution. + phaseRunners []*phaseRunner +} + +// phaseRunner provides a wrapper to a Phase with the addition of a set +// of contextual information derived by the workflow managed by the Runner. +// TODO: If we ever decide to get more sophisticated we can swap this type with a well defined dag or tree library. +type phaseRunner struct { + // Phase provide access to the phase implementation + Phase + + // provide access to the parent phase in the workflow managed by the Runner. + parent *phaseRunner + + // level define the level of nesting of this phase into the workflow managed by + // the Runner. + level int + + // selfPath contains all the elements of the path that identify the phase into + // the workflow managed by the Runner. + selfPath []string + + // generatedName is the full name of the phase, that corresponds to the absolute + // path of the phase in the the workflow managed by the Runner. + generatedName string + + // use is the phase usage string that will be printed in the workflow help. + // It corresponds to the relative path of the phase in the workflow managed by the Runner. + use string +} + +// NewRunner return a new runner for composable kubeadm workflows. +func NewRunner() *Runner { + return &Runner{ + Phases: []Phase{}, + } +} + +// AppendPhase adds the given phase to the ordered sequence of phases managed by the runner. +func (e *Runner) AppendPhase(t Phase) { + e.Phases = append(e.Phases, t) +} + +// computePhaseRunFlags return a map defining which phase should be run and which not. +// PhaseRunFlags are computed according to RunnerOptions. +func (e *Runner) computePhaseRunFlags() (map[string]bool, error) { + // Initialize support data structure + phaseRunFlags := map[string]bool{} + phaseHierarchy := map[string][]string{} + e.visitAll(func(p *phaseRunner) error { + // Initialize phaseRunFlags assuming that all the phases should be run. + phaseRunFlags[p.generatedName] = true + + // Initialize phaseHierarchy for the current phase (the list of phases + // depending on the current phase + phaseHierarchy[p.generatedName] = []string{} + + // Register current phase as part of its own parent hierarchy + parent := p.parent + for parent != nil { + phaseHierarchy[parent.generatedName] = append(phaseHierarchy[parent.generatedName], p.generatedName) + parent = parent.parent + } + return nil + }) + + // If a filter option is specified, set all phaseRunFlags to false except for + // the phases included in the filter and their hierarchy of nested phases. + if len(e.Options.FilterPhases) > 0 { + for i := range phaseRunFlags { + phaseRunFlags[i] = false + } + for _, f := range e.Options.FilterPhases { + if _, ok := phaseRunFlags[f]; !ok { + return phaseRunFlags, fmt.Errorf("invalid phase name: %s", f) + } + phaseRunFlags[f] = true + for _, c := range phaseHierarchy[f] { + phaseRunFlags[c] = true + } + } + } + + // If a phase skip option is specified, set the corresponding phaseRunFlags + // to false and apply the same change to the underlying hierarchy + for _, f := range e.Options.SkipPhases { + if _, ok := phaseRunFlags[f]; !ok { + return phaseRunFlags, fmt.Errorf("invalid phase name: %s", f) + } + phaseRunFlags[f] = false + for _, c := range phaseHierarchy[f] { + phaseRunFlags[c] = false + } + } + + return phaseRunFlags, nil +} + +// SetDataInitializer allows to setup a function that initialize the runtime data shared +// among all the phases included in the workflow. +func (e *Runner) SetDataInitializer(builder func() (RunData, error)) { + e.runDataInitializer = builder +} + +// InitData triggers the creation of runtime data shared among all the phases included in the workflow. +// This action can be executed explicitly out, when it is necessary to get the RunData +// before actually executing Run, or implicitly when invoking Run. +func (e *Runner) InitData() (RunData, error) { + if e.runData == nil && e.runDataInitializer != nil { + var err error + if e.runData, err = e.runDataInitializer(); err != nil { + return nil, err + } + } + + return e.runData, nil +} + +// Run the kubeadm composable kubeadm workflows. +func (e *Runner) Run() error { + e.prepareForExecution() + + // determine which phase should be run according to RunnerOptions + phaseRunFlags, err := e.computePhaseRunFlags() + if err != nil { + return err + } + + // builds the runner data + var data RunData + if data, err = e.InitData(); err != nil { + return err + } + + err = e.visitAll(func(p *phaseRunner) error { + // if the phase should not be run, skip the phase. + if run, ok := phaseRunFlags[p.generatedName]; !run || !ok { + return nil + } + + // If the phase defines a condition to be checked before executing the phase action. + if p.RunIf != nil { + // Check the condition and returns if the condition isn't satisfied (or fails) + ok, err := p.RunIf(data) + if err != nil { + return fmt.Errorf("error execution run condition for phase %s: %v", p.generatedName, err) + } + + if !ok { + return nil + } + } + + // Runs the phase action (if defined) + if p.Run != nil { + if err := p.Run(data); err != nil { + return fmt.Errorf("error execution phase %s: %v", p.generatedName, err) + } + } + + return nil + }) + + return err +} + +// Help returns text with the list of phases included in the workflow. +func (e *Runner) Help(cmdUse string) string { + e.prepareForExecution() + + // computes the max length of for each phase use line + maxLength := 0 + e.visitAll(func(p *phaseRunner) error { + if !p.Hidden { + length := len(p.use) + if maxLength < length { + maxLength = length + } + } + return nil + }) + + // prints the list of phases indented by level and formatted using the maxlength + // the list is enclosed in a mardown code block for ensuring better readability in the public web site + line := fmt.Sprintf("The %q command executes the following internal workflow:\n", cmdUse) + line += "```\n" + offset := 2 + e.visitAll(func(p *phaseRunner) error { + if !p.Hidden { + padding := maxLength - len(p.use) + offset + line += strings.Repeat(" ", offset*p.level) // indentation + line += p.use // name + aliases + line += strings.Repeat(" ", padding) // padding right up to max length (+ offset for spacing) + line += p.Short // phase short description + line += "\n" + } + + return nil + }) + line += "```" + return line +} + +// BindToCommand bind the Runner to a cobra command by altering +// command help, adding phase related flags and by adding phases subcommands +// Please note that this command needs to be done once all the phases are added to the Runner. +func (e *Runner) BindToCommand(cmd *cobra.Command) { + if len(e.Phases) == 0 { + return + } + + // alters the command description to show available phases + if cmd.Long != "" { + cmd.Long = fmt.Sprintf("%s\n\n%s\n", cmd.Long, e.Help(cmd.Use)) + } else { + cmd.Long = fmt.Sprintf("%s\n\n%s\n", cmd.Short, e.Help(cmd.Use)) + } + + // adds phase related flags + cmd.Flags().StringSliceVar(&e.Options.SkipPhases, "skip-phases", nil, "List of phases to be skipped") + + // adds the phases subcommand + phaseCommand := &cobra.Command{ + Use: "phase", + Short: fmt.Sprintf("use this command to invoke single phase of the %s workflow", cmd.Name()), + Args: cobra.NoArgs, // this forces cobra to fail if a wrong phase name is passed + } + + cmd.AddCommand(phaseCommand) + + // generate all the nested subcommands for invoking single phases + subcommands := map[string]*cobra.Command{} + e.visitAll(func(p *phaseRunner) error { + // creates nested phase subcommand + var phaseCmd = &cobra.Command{ + Use: strings.ToLower(p.Name), + Short: p.Short, + Long: p.Long, + Example: p.Example, + Run: func(cmd *cobra.Command, args []string) { + e.Options.FilterPhases = []string{p.generatedName} + if err := e.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }, + Args: cobra.NoArgs, // this forces cobra to fail if a wrong phase name is passed + } + + // makes the new command inherits flags from the main command + cmd.LocalNonPersistentFlags().VisitAll(func(f *pflag.Flag) { + phaseCmd.Flags().AddFlag(f) + }) + + // adds the command to parent + if p.level == 0 { + phaseCommand.AddCommand(phaseCmd) + } else { + subcommands[p.parent.generatedName].AddCommand(phaseCmd) + } + + subcommands[p.generatedName] = phaseCmd + return nil + }) +} + +// visitAll provides a utility method for visiting all the phases in the workflow +// in the execution order and executing a func on each phase. +// Nested phase are visited immediately after their parent phase. +func (e *Runner) visitAll(fn func(*phaseRunner) error) error { + for _, currentRunner := range e.phaseRunners { + if err := fn(currentRunner); err != nil { + return err + } + } + return nil +} + +// prepareForExecution initialize the internal state of the Runner (the list of phaseRunner). +func (e *Runner) prepareForExecution() { + e.phaseRunners = []*phaseRunner{} + var parentRunner *phaseRunner + for _, phase := range e.Phases { + addPhaseRunner(e, parentRunner, phase) + } +} + +// addPhaseRunner adds the phaseRunner for a given phase to the phaseRunners list +func addPhaseRunner(e *Runner, parentRunner *phaseRunner, phase Phase) { + // computes contextual information derived by the workflow managed by the Runner. + generatedName := strings.ToLower(phase.Name) + use := generatedName + selfPath := []string{generatedName} + + if parentRunner != nil { + generatedName = strings.Join([]string{parentRunner.generatedName, generatedName}, phaseSeparator) + use = fmt.Sprintf("%s%s", phaseSeparator, use) + selfPath = append(parentRunner.selfPath, selfPath...) + } + + // creates the phaseRunner + currentRunner := &phaseRunner{ + Phase: phase, + parent: parentRunner, + level: len(selfPath) - 1, + selfPath: selfPath, + generatedName: generatedName, + use: use, + } + + // adds to the phaseRunners list + e.phaseRunners = append(e.phaseRunners, currentRunner) + + // iterate for the nested, ordered list of phases, thus storing + // phases in the expected executing order (child phase are stored immediately after their parent phase). + for _, childPhase := range phase.Phases { + addPhaseRunner(e, currentRunner, childPhase) + } +} diff --git a/cmd/kubeadm/app/cmd/phases/workflow/runner_test.go b/cmd/kubeadm/app/cmd/phases/workflow/runner_test.go new file mode 100644 index 0000000000..3aedf6acb7 --- /dev/null +++ b/cmd/kubeadm/app/cmd/phases/workflow/runner_test.go @@ -0,0 +1,279 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package workflow + +import ( + "errors" + "fmt" + "reflect" + "testing" +) + +func phaseBuilder(name string, phases ...Phase) Phase { + return Phase{ + Name: name, + Short: fmt.Sprintf("long description for %s ...", name), + Phases: phases, + } +} + +func TestComputePhaseRunFlags(t *testing.T) { + + var usecases = []struct { + name string + options RunnerOptions + expected map[string]bool + expectedError bool + }{ + { + name: "no options > all phases", + options: RunnerOptions{}, + expected: map[string]bool{"foo": true, "foo/bar": true, "foo/baz": true, "qux": true}, + }, + { + name: "options can filter phases", + options: RunnerOptions{FilterPhases: []string{"foo/baz", "qux"}}, + expected: map[string]bool{"foo": false, "foo/bar": false, "foo/baz": true, "qux": true}, + }, + { + name: "options can filter phases - hierarchy is considered", + options: RunnerOptions{FilterPhases: []string{"foo"}}, + expected: map[string]bool{"foo": true, "foo/bar": true, "foo/baz": true, "qux": false}, + }, + { + name: "options can skip phases", + options: RunnerOptions{SkipPhases: []string{"foo/bar", "qux"}}, + expected: map[string]bool{"foo": true, "foo/bar": false, "foo/baz": true, "qux": false}, + }, + { + name: "options can skip phases - hierarchy is considered", + options: RunnerOptions{SkipPhases: []string{"foo"}}, + expected: map[string]bool{"foo": false, "foo/bar": false, "foo/baz": false, "qux": true}, + }, + { + name: "skip options have higher precedence than filter options", + options: RunnerOptions{ + FilterPhases: []string{"foo"}, // "foo", "foo/bar", "foo/baz" true + SkipPhases: []string{"foo/bar"}, // "foo/bar" false + }, + expected: map[string]bool{"foo": true, "foo/bar": false, "foo/baz": true, "qux": false}, + }, + { + name: "invalid filter option", + options: RunnerOptions{FilterPhases: []string{"invalid"}}, + expectedError: true, + }, + { + name: "invalid skip option", + options: RunnerOptions{SkipPhases: []string{"invalid"}}, + expectedError: true, + }, + } + for _, u := range usecases { + t.Run(u.name, func(t *testing.T) { + var w = Runner{ + Phases: []Phase{ + phaseBuilder("foo", + phaseBuilder("bar"), + phaseBuilder("baz"), + ), + phaseBuilder("qux"), + }, + } + + w.prepareForExecution() + w.Options = u.options + actual, err := w.computePhaseRunFlags() + if (err != nil) != u.expectedError { + t.Errorf("Unexpected error: %v", err) + } + if err != nil { + return + } + if !reflect.DeepEqual(actual, u.expected) { + t.Errorf("\nactual:\n\t%v\nexpected:\n\t%v\n", actual, u.expected) + } + }) + } +} + +func phaseBuilder1(name string, runIf func(data RunData) (bool, error), phases ...Phase) Phase { + return Phase{ + Name: name, + Short: fmt.Sprintf("long description for %s ...", name), + Phases: phases, + Run: runBuilder(name), + RunIf: runIf, + } +} + +var callstack []string + +func runBuilder(name string) func(data RunData) error { + return func(data RunData) error { + callstack = append(callstack, name) + return nil + } +} + +func runConditionTrue(data RunData) (bool, error) { + return true, nil +} + +func runConditionFalse(data RunData) (bool, error) { + return false, nil +} + +func TestRunOrderAndConditions(t *testing.T) { + var w = Runner{ + Phases: []Phase{ + phaseBuilder1("foo", nil, + phaseBuilder1("bar", runConditionTrue), + phaseBuilder1("baz", runConditionFalse), + ), + phaseBuilder1("qux", runConditionTrue), + }, + } + + var usecases = []struct { + name string + options RunnerOptions + expectedOrder []string + }{ + { + name: "Run respect runCondition", + expectedOrder: []string{"foo", "bar", "qux"}, + }, + { + name: "Run takes options into account", + options: RunnerOptions{FilterPhases: []string{"foo"}, SkipPhases: []string{"foo/baz"}}, + expectedOrder: []string{"foo", "bar"}, + }, + } + for _, u := range usecases { + t.Run(u.name, func(t *testing.T) { + callstack = []string{} + w.Options = u.options + err := w.Run() + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !reflect.DeepEqual(callstack, u.expectedOrder) { + t.Errorf("\ncallstack:\n\t%v\nexpected:\n\t%v\n", callstack, u.expectedOrder) + } + }) + } +} + +func phaseBuilder2(name string, runIf func(data RunData) (bool, error), run func(data RunData) error, phases ...Phase) Phase { + return Phase{ + Name: name, + Short: fmt.Sprintf("long description for %s ...", name), + Phases: phases, + Run: run, + RunIf: runIf, + } +} + +func runPass(data RunData) error { + return nil +} + +func runFails(data RunData) error { + return errors.New("run fails") +} + +func runConditionPass(data RunData) (bool, error) { + return true, nil +} + +func runConditionFails(data RunData) (bool, error) { + return false, errors.New("run condition fails") +} + +func TestRunHandleErrors(t *testing.T) { + var w = Runner{ + Phases: []Phase{ + phaseBuilder2("foo", runConditionPass, runPass), + phaseBuilder2("bar", runConditionPass, runFails), + phaseBuilder2("baz", runConditionFails, runPass), + }, + } + + var usecases = []struct { + name string + options RunnerOptions + expectedError bool + }{ + { + name: "no errors", + options: RunnerOptions{FilterPhases: []string{"foo"}}, + }, + { + name: "run fails", + options: RunnerOptions{FilterPhases: []string{"bar"}}, + expectedError: true, + }, + { + name: "run condition fails", + options: RunnerOptions{FilterPhases: []string{"baz"}}, + expectedError: true, + }, + } + for _, u := range usecases { + t.Run(u.name, func(t *testing.T) { + w.Options = u.options + err := w.Run() + if (err != nil) != u.expectedError { + t.Errorf("Unexpected error: %v", err) + } + }) + } +} + +func phaseBuilder3(name string, hidden bool, phases ...Phase) Phase { + return Phase{ + Name: name, + Short: fmt.Sprintf("long description for %s ...", name), + Phases: phases, + Hidden: hidden, + } +} + +func TestHelp(t *testing.T) { + var w = Runner{ + Phases: []Phase{ + phaseBuilder3("foo", false, + phaseBuilder3("bar", false), + phaseBuilder3("baz", true), + ), + phaseBuilder3("qux", false), + }, + } + + expected := "The \"myCommand\" command executes the following internal workflow:\n" + + "```\n" + + "foo long description for foo ...\n" + + " /bar long description for bar ...\n" + + "qux long description for qux ...\n" + + "```" + + actual := w.Help("myCommand") + if !reflect.DeepEqual(actual, expected) { + t.Errorf("\nactual:\n\t%v\nexpected:\n\t%v\n", actual, expected) + } +}