diff --git a/pkg/apis/kudo/v1beta1/operatorversion_types.go b/pkg/apis/kudo/v1beta1/operatorversion_types.go index 478fc27fc..c305c9b0c 100644 --- a/pkg/apis/kudo/v1beta1/operatorversion_types.go +++ b/pkg/apis/kudo/v1beta1/operatorversion_types.go @@ -141,26 +141,26 @@ type TaskSpec struct { type ResourceTaskSpec struct { // +optional // +nullable - Resources []string `json:"resources"` + Resources []string `json:"resources,omitempty"` } // DummyTaskSpec can succeed or fail on demand and is very useful for testing operators type DummyTaskSpec struct { // +optional - WantErr bool `json:"wantErr"` + WantErr bool `json:"wantErr,omitempty"` // +optional - Fatal bool `json:"fatal"` + Fatal bool `json:"fatal,omitempty"` // +optional - Done bool `json:"done"` + Done bool `json:"done,omitempty"` } // PipeTask specifies a task that generates files and stores them for later usage in subsequent tasks type PipeTaskSpec struct { // +optional - Pod string `json:"pod"` + Pod string `json:"pod,omitempty"` // +optional // +nullable - Pipe []PipeSpec `json:"pipe"` + Pipe []PipeSpec `json:"pipe,omitempty"` } // PipeSpec describes how a file generated by a PipeTask is stored and referenced diff --git a/pkg/kudoctl/cmd/generate/maintainer.go b/pkg/kudoctl/cmd/generate/maintainer.go new file mode 100644 index 000000000..23ddfc5b1 --- /dev/null +++ b/pkg/kudoctl/cmd/generate/maintainer.go @@ -0,0 +1,32 @@ +package generate + +import ( + "github.com/spf13/afero" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/kudoctl/packages/reader" +) + +// AddMaintainer adds a maintainer to the operator.yaml +func AddMaintainer(fs afero.Fs, path string, m *v1beta1.Maintainer) error { + + p, err := reader.ReadDir(fs, path) + if err != nil { + return err + } + o := p.Files.Operator + + o.Maintainers = append(o.Maintainers, m) + + return writeOperator(fs, path, o) +} + +// MaintainerList provides a list of operator maintainers +func MaintainerList(fs afero.Fs, path string) ([]*v1beta1.Maintainer, error) { + p, err := reader.ReadDir(fs, path) + if err != nil { + return nil, err + } + + return p.Files.Operator.Maintainers, nil +} diff --git a/pkg/kudoctl/cmd/generate/maintainer_test.go b/pkg/kudoctl/cmd/generate/maintainer_test.go new file mode 100644 index 000000000..c60a127f7 --- /dev/null +++ b/pkg/kudoctl/cmd/generate/maintainer_test.go @@ -0,0 +1,56 @@ +package generate + +import ( + "flag" + "io/ioutil" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/kudoctl/files" +) + +var updateGolden = flag.Bool("update", false, "update .golden files and manifests in /config/crd") + +func TestAddMaintainer(t *testing.T) { + goldenFile := "maintainer" + fs := afero.NewMemMapFs() + files.CopyOperatorToFs(fs, "../../packages/testdata/zk", "/opt") + m := v1beta1.Maintainer{ + Name: "Cat in the hat", + Email: "c@hat.com", + } + + err := AddMaintainer(fs, "/opt/zk", &m) + assert.NoError(t, err) + + operator, err := afero.ReadFile(fs, "/opt/zk/operator.yaml") + assert.NoError(t, err) + + gp := filepath.Join("testdata", goldenFile+".golden") + + if *updateGolden { + t.Logf("updating golden file %s", goldenFile) + if err := ioutil.WriteFile(gp, operator, 0644); err != nil { + t.Fatalf("failed to update golden file: %s", err) + } + } + g, err := ioutil.ReadFile(gp) + if err != nil { + t.Fatalf("failed reading .golden: %s", err) + } + + assert.Equal(t, g, operator, "for golden file: %s", gp) +} + +func TestListMaintainers(t *testing.T) { + fs := afero.OsFs{} + m, err := MaintainerList(fs, "../../packages/testdata/zk") + assert.NoError(t, err) + + assert.Equal(t, 3, len(m)) + assert.Equal(t, "Alena Varkockova", m[0].Name) +} diff --git a/pkg/kudoctl/cmd/generate/operator.go b/pkg/kudoctl/cmd/generate/operator.go index cbd3bd9f9..d47936c56 100644 --- a/pkg/kudoctl/cmd/generate/operator.go +++ b/pkg/kudoctl/cmd/generate/operator.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "path" + "path/filepath" "github.com/spf13/afero" "sigs.k8s.io/yaml" @@ -36,7 +37,7 @@ func CanGenerateOperator(fs afero.Fs, dir string, overwrite bool) error { } // Operator generates an initial operator folder with a operator.yaml -func Operator(fs afero.Fs, dir string, op packages.OperatorFile, overwrite bool) error { +func Operator(fs afero.Fs, dir string, op *packages.OperatorFile, overwrite bool) error { err := CanGenerateOperator(fs, dir, overwrite) if err != nil { return err @@ -90,7 +91,7 @@ func writeParameters(fs afero.Fs, dir string, params packages.ParamsFile) error return afero.WriteFile(fs, fname, p, 0755) } -func writeOperator(fs afero.Fs, dir string, op packages.OperatorFile) error { +func writeOperator(fs afero.Fs, dir string, op *packages.OperatorFile) error { o, err := yaml.Marshal(op) if err != nil { return err @@ -99,3 +100,33 @@ func writeOperator(fs afero.Fs, dir string, op packages.OperatorFile) error { fname := path.Join(dir, reader.OperatorFileName) return afero.WriteFile(fs, fname, o, 0755) } + +// OperatorPath determines the path to use as operator path for generators +// the path is either current "", or a dir with operator.yaml (if 1) else an error +// and is determined based on location of operator.yaml +func OperatorPath(fs afero.Fs) (string, error) { + fname := "operator.yaml" + + exists, err := afero.Exists(fs, fname) + if err != nil { + return "", err + } + + if exists { + return "", nil + } + + pat := path.Join("**", fname) + // one more try + files, err := afero.Glob(fs, pat) + if err != nil { + return "", err + } + if len(files) < 1 { + return "", errors.New("no operator folder discovered") + } + if len(files) > 1 { + return "", errors.New("multiple operator folders discovered") + } + return filepath.Dir(files[0]), nil +} diff --git a/pkg/kudoctl/cmd/generate/operator_test.go b/pkg/kudoctl/cmd/generate/operator_test.go index 5981d338b..bf74ce7a0 100644 --- a/pkg/kudoctl/cmd/generate/operator_test.go +++ b/pkg/kudoctl/cmd/generate/operator_test.go @@ -47,7 +47,7 @@ func TestOperator_Write(t *testing.T) { fs := afero.NewMemMapFs() - err := Operator(fs, "operator", op1, false) + err := Operator(fs, "operator", &op1, false) // no error on create assert.Nil(t, err) @@ -59,11 +59,11 @@ func TestOperator_Write(t *testing.T) { assert.True(t, exists) // test fail on existing - err = Operator(fs, "operator", op1, false) + err = Operator(fs, "operator", &op1, false) assert.Errorf(t, err, "folder 'operator' already exists") // test overwriting with no error - err = Operator(fs, "operator", op1, true) + err = Operator(fs, "operator", &op1, true) // no error on overwrite assert.Nil(t, err) @@ -76,7 +76,7 @@ func TestOperator_Write(t *testing.T) { err = writeParameters(fs, "operator", pf) assert.Nil(t, err) // test overwriting with no error - err = Operator(fs, "operator", op1, true) + err = Operator(fs, "operator", &op1, true) // no error on overwrite assert.Nil(t, err) parmfile, _ := afero.ReadFile(fs, paramFilename) diff --git a/pkg/kudoctl/cmd/generate/parameter.go b/pkg/kudoctl/cmd/generate/parameter.go new file mode 100644 index 000000000..ec66ab28a --- /dev/null +++ b/pkg/kudoctl/cmd/generate/parameter.go @@ -0,0 +1,35 @@ +package generate + +import ( + "github.com/spf13/afero" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/kudoctl/packages/reader" +) + +// AddParameter writes a parameter to the params.yaml file +func AddParameter(fs afero.Fs, path string, p *v1beta1.Parameter) error { + + pf, err := reader.ReadDir(fs, path) + if err != nil { + return err + } + + params := pf.Files.Params + params.Parameters = append(params.Parameters, *p) + + return writeParameters(fs, path, *params) +} + +func ParameterNameList(fs afero.Fs, path string) (paramNames []string, err error) { + pf, err := reader.ReadDir(fs, path) + if err != nil { + return nil, err + } + + for _, parameter := range pf.Files.Params.Parameters { + paramNames = append(paramNames, parameter.Name) + } + + return paramNames, nil +} diff --git a/pkg/kudoctl/cmd/generate/parameter_test.go b/pkg/kudoctl/cmd/generate/parameter_test.go new file mode 100644 index 000000000..02bb5d033 --- /dev/null +++ b/pkg/kudoctl/cmd/generate/parameter_test.go @@ -0,0 +1,71 @@ +package generate + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/kudoctl/files" +) + +func TestAddParameter(t *testing.T) { + goldenFile := "parameter" + path := "/opt/zk" + fs := afero.NewMemMapFs() + files.CopyOperatorToFs(fs, "../../packages/testdata/zk", "/opt") + + bar := "Bar" + p := v1beta1.Parameter{ + Name: "Foo", + Default: &bar, + } + + err := AddParameter(fs, path, &p) + assert.NoError(t, err) + + params, err := afero.ReadFile(fs, "/opt/zk/params.yaml") + assert.NoError(t, err) + + gp := filepath.Join("testdata", goldenFile+".golden") + + if *updateGolden { + t.Logf("updating golden file %s", goldenFile) + if err := ioutil.WriteFile(gp, params, 0644); err != nil { + t.Fatalf("failed to update golden file: %s", err) + } + } + golden, err := ioutil.ReadFile(gp) + if err != nil { + t.Fatalf("failed reading .golden: %s", err) + } + + assert.Equal(t, golden, params, "for golden file: %s", gp) +} + +func TestAddParameter_bad_path(t *testing.T) { + path, _ := os.Getwd() + fs := afero.OsFs{} + + bar := "Bar" + p := v1beta1.Parameter{ + Name: "Foo", + Default: &bar, + } + + err := AddParameter(fs, path, &p) + assert.Error(t, err) +} + +func TestListParams(t *testing.T) { + fs := afero.OsFs{} + ps, err := ParameterNameList(fs, "../../packages/testdata/zk") + assert.NoError(t, err) + + assert.Equal(t, 2, len(ps)) + assert.Equal(t, "memory", ps[0]) +} diff --git a/pkg/kudoctl/cmd/generate/plans.go b/pkg/kudoctl/cmd/generate/plans.go new file mode 100644 index 000000000..6b5f4c620 --- /dev/null +++ b/pkg/kudoctl/cmd/generate/plans.go @@ -0,0 +1,51 @@ +package generate + +import ( + "sort" + + "github.com/spf13/afero" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/kudoctl/packages/reader" +) + +// AddPlan adds a plan to the operator.yaml file +func AddPlan(fs afero.Fs, path string, planName string, plan *v1beta1.Plan) error { + + pf, err := reader.ReadDir(fs, path) + if err != nil { + return err + } + + o := pf.Files.Operator + plans := o.Plans + plans[planName] = *plan + pf.Files.Operator.Plans = plans + + return writeOperator(fs, path, o) +} + +// PlanList provides a list of operator plans +func PlanList(fs afero.Fs, path string) (map[string]v1beta1.Plan, error) { + p, err := reader.ReadDir(fs, path) + if err != nil { + return nil, err + } + + return p.Files.Operator.Plans, nil +} + +// PlanNameList provides a list of operator plan names +func PlanNameList(fs afero.Fs, path string) ([]string, error) { + + names := []string{} + p, err := reader.ReadDir(fs, path) + if err != nil { + return nil, err + } + for name := range p.Files.Operator.Plans { + names = append(names, name) + } + sort.Strings(names) + return names, nil +} diff --git a/pkg/kudoctl/cmd/generate/plans_test.go b/pkg/kudoctl/cmd/generate/plans_test.go new file mode 100644 index 000000000..f04e2e91d --- /dev/null +++ b/pkg/kudoctl/cmd/generate/plans_test.go @@ -0,0 +1,92 @@ +package generate + +import ( + "io/ioutil" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/kudoctl/files" +) + +func TestAddPlan(t *testing.T) { + goldenFile := "plan" + path := "/opt/zk" + fs := afero.NewMemMapFs() + files.CopyOperatorToFs(fs, "../../packages/testdata/zk", "/opt") + + name := "flush" + p := planToFlush() + + err := AddPlan(fs, path, name, &p) + assert.NoError(t, err) + + params, err := afero.ReadFile(fs, "/opt/zk/operator.yaml") + assert.NoError(t, err) + + gp := filepath.Join("testdata", goldenFile+".golden") + + if *updateGolden { + t.Logf("updating golden file %s", goldenFile) + if err := ioutil.WriteFile(gp, params, 0644); err != nil { + t.Fatalf("failed to update golden file: %s", err) + } + } + golden, err := ioutil.ReadFile(gp) + if err != nil { + t.Fatalf("failed reading .golden: %s", err) + } + + assert.Equal(t, golden, params, "for golden file: %s", gp) +} + +func planToFlush() v1beta1.Plan { + steps := []v1beta1.Step{{ + Name: "push-lever", + Tasks: []string{"lower-lever"}, + }} + + phases := []v1beta1.Phase{{ + Name: "flush", + Strategy: "serial", + Steps: steps, + }} + + p := v1beta1.Plan{ + Strategy: "serial", + Phases: phases, + } + return p +} + +func TestAddPlan_bad_path(t *testing.T) { + path := "." + fs := afero.OsFs{} + + name := "flush" + p := planToFlush() + + err := AddPlan(fs, path, name, &p) + assert.Error(t, err) +} + +func TestListPlanNames(t *testing.T) { + fs := afero.OsFs{} + p, err := PlanNameList(fs, "../../packages/testdata/zk") + assert.NoError(t, err) + + assert.Equal(t, 2, len(p)) + assert.Equal(t, "deploy", p[0]) +} + +func TestListPlans(t *testing.T) { + fs := afero.OsFs{} + planMap, err := PlanList(fs, "../../packages/testdata/zk") + assert.NoError(t, err) + + assert.Equal(t, 2, len(planMap)) + assert.Equal(t, "zookeeper", planMap["deploy"].Phases[0].Name) +} diff --git a/pkg/kudoctl/cmd/generate/task.go b/pkg/kudoctl/cmd/generate/task.go new file mode 100644 index 000000000..5778d1e0b --- /dev/null +++ b/pkg/kudoctl/cmd/generate/task.go @@ -0,0 +1,112 @@ +package generate + +import ( + "fmt" + "path" + + "github.com/spf13/afero" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/engine/task" + "github.com/kudobuilder/kudo/pkg/kudoctl/packages/reader" +) + +// AddTask adds a task to the operator.yaml file +func AddTask(fs afero.Fs, path string, task *v1beta1.Task) error { + p, err := reader.ReadDir(fs, path) + if err != nil { + return err + } + o := p.Files.Operator + + o.Tasks = append(o.Tasks, *task) + + return writeOperator(fs, path, o) +} + +// TaskList provides a list of operator tasks +func TaskList(fs afero.Fs, path string) ([]v1beta1.Task, error) { + p, err := reader.ReadDir(fs, path) + if err != nil { + return nil, err + } + + return p.Files.Operator.Tasks, nil +} + +func TaskInList(fs afero.Fs, path, taskName string) (bool, error) { + list, err := TaskList(fs, path) + if err != nil { + return false, err + } + for _, task := range list { + if task.Name == taskName { + return true, nil + } + } + return false, nil +} + +// TaskKinds provides a list of task kinds what are supported via generators +func TaskKinds() []string { + return []string{task.ApplyTaskKind, task.DeleteTaskKind, task.PipeTaskKind} +} + +// EnsureTaskResources ensures that all resources used by the given task exist +func EnsureTaskResources(fs afero.Fs, path string, task *v1beta1.Task) error { + + for _, resource := range task.Spec.Resources { + err := EnsureResource(fs, path, resource) + if err != nil { + return err + } + } + + if task.Spec.Pod != "" { + err := EnsureResource(fs, path, task.Spec.Pod) + if err != nil { + return err + } + } + return nil +} + +// EnsureResource ensures that resource is in templates folder +func EnsureResource(fs afero.Fs, dir string, resource string) error { + + // does "operator" path exist? if not err + exists, err := afero.DirExists(fs, dir) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("operator path %q does not exist", dir) + } + + // does templates folder exist? if not mkdir + templatePath := path.Join(dir, "templates") + exists, err = afero.DirExists(fs, templatePath) + if err != nil { + return err + } + if !exists { + err = fs.Mkdir(templatePath, 0755) + if err != nil { + return err + } + } + + // does resource exist? if not "touch" it, otherwise good + resourcePath := path.Join(dir, "templates", resource) + exists, err = afero.Exists(fs, resourcePath) + if err != nil { + return err + } + if !exists { + err = afero.WriteFile(fs, resourcePath, []byte{}, 0755) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/kudoctl/cmd/generate/task_test.go b/pkg/kudoctl/cmd/generate/task_test.go new file mode 100644 index 000000000..fbe95fd49 --- /dev/null +++ b/pkg/kudoctl/cmd/generate/task_test.go @@ -0,0 +1,118 @@ +package generate + +import ( + "io/ioutil" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/kudoctl/files" +) + +func TestAddTask(t *testing.T) { + goldenFile := "task" + path := "/opt/zk" + fs := afero.NewMemMapFs() + files.CopyOperatorToFs(fs, "../../packages/testdata/zk", "/opt") + + task := fooTask() + + err := AddTask(fs, path, &task) + assert.NoError(t, err) + + operator, err := afero.ReadFile(fs, "/opt/zk/operator.yaml") + assert.NoError(t, err) + + gp := filepath.Join("testdata", goldenFile+".golden") + + if *updateGolden { + t.Logf("updating golden file %s", goldenFile) + if err := ioutil.WriteFile(gp, operator, 0644); err != nil { + t.Fatalf("failed to update golden file: %s", err) + } + } + golden, err := ioutil.ReadFile(gp) + if err != nil { + t.Fatalf("failed reading .golden: %s", err) + } + + assert.Equal(t, string(golden), string(operator), "for golden file: %s", gp) +} + +func TestEnsureTaskResources(t *testing.T) { + path := "/opt/zk" + fs := afero.NewMemMapFs() + files.CopyOperatorToFs(fs, "../../packages/testdata/zk", "/opt") + + task := fooTask() + + // ensure an apply task saves + err := EnsureTaskResources(fs, path, &task) + assert.NoError(t, err) + + ok, err := afero.Exists(fs, "/opt/zk/templates/bar.yaml") + assert.NoError(t, err) + assert.True(t, ok) + + // ensure pipe task saves + task = pipeTask() + err = EnsureTaskResources(fs, path, &task) + assert.NoError(t, err) + + ok, err = afero.Exists(fs, "/opt/zk/templates/pipe-pod.yaml") + assert.NoError(t, err) + assert.True(t, ok) +} + +func TestAddTask_bad_path(t *testing.T) { + path := "." + fs := afero.OsFs{} + + task := fooTask() + + err := AddTask(fs, path, &task) + assert.Error(t, err) +} + +func fooTask() v1beta1.Task { + res := v1beta1.ResourceTaskSpec{Resources: []string{"bar.yaml"}} + task := v1beta1.Task{ + Name: "Foo", + Kind: "Apply", + Spec: v1beta1.TaskSpec{ResourceTaskSpec: res}} + return task +} + +func pipeTask() v1beta1.Task { + res := v1beta1.PipeTaskSpec{Pod: "pipe-pod.yaml"} + task := v1beta1.Task{ + Name: "Foo", + Kind: "Pipe", + Spec: v1beta1.TaskSpec{PipeTaskSpec: res}} + return task +} + +func TestListTaskNames(t *testing.T) { + fs := afero.OsFs{} + tasks, err := TaskList(fs, "../../packages/testdata/zk") + assert.NoError(t, err) + + assert.Equal(t, 3, len(tasks)) + assert.Equal(t, "infra", tasks[0].Name) +} + +func TestTaskInList(t *testing.T) { + fs := afero.OsFs{} + name := "app" + check, err := TaskInList(fs, "../../packages/testdata/zk", name) + assert.NoError(t, err) + assert.True(t, check) + + name = "wth" + check, err = TaskInList(fs, "../../packages/testdata/zk", name) + assert.NoError(t, err) + assert.False(t, check) +} diff --git a/pkg/kudoctl/cmd/generate/testdata/maintainer.golden b/pkg/kudoctl/cmd/generate/testdata/maintainer.golden new file mode 100644 index 000000000..84ea47b48 --- /dev/null +++ b/pkg/kudoctl/cmd/generate/testdata/maintainer.golden @@ -0,0 +1,53 @@ +apiVersion: kudo.dev/v1beta1 +appVersion: 3.4.10 +kubernetesVersion: 1.15.0 +kudoVersion: 0.2.0 +maintainers: +- email: avarkockova@mesosphere.com + name: Alena Varkockova +- email: runyontr@gmail.com + name: Tom Runyon +- email: kensipe@gmail.com + name: Ken Sipe +- email: c@hat.com + name: Cat in the hat +name: zookeeper +plans: + deploy: + phases: + - name: zookeeper + steps: + - name: everything + tasks: + - infra + - app + strategy: parallel + strategy: serial + validation: + phases: + - name: connection + steps: + - name: connection + tasks: + - validation + strategy: parallel + strategy: serial +tasks: +- kind: Apply + name: infra + spec: + resources: + - services.yaml + - pdb.yaml +- kind: Apply + name: app + spec: + resources: + - statefulset.yaml +- kind: Apply + name: validation + spec: + resources: + - validation.yaml +url: https://zookeeper.apache.org/ +version: 0.1.0 diff --git a/pkg/kudoctl/cmd/generate/testdata/parameter.golden b/pkg/kudoctl/cmd/generate/testdata/parameter.golden new file mode 100644 index 000000000..17d484623 --- /dev/null +++ b/pkg/kudoctl/cmd/generate/testdata/parameter.golden @@ -0,0 +1,12 @@ +apiVersion: kudo.dev/v1beta1 +parameters: +- default: 1Gi + description: Amount of memory to provide to Zookeeper pods + name: memory + required: true +- default: "0.25" + description: Amount of cpu to provide to Zookeeper pods + name: cpus + required: true +- default: Bar + name: Foo diff --git a/pkg/kudoctl/cmd/generate/testdata/plan.golden b/pkg/kudoctl/cmd/generate/testdata/plan.golden new file mode 100644 index 000000000..02cb792a4 --- /dev/null +++ b/pkg/kudoctl/cmd/generate/testdata/plan.golden @@ -0,0 +1,60 @@ +apiVersion: kudo.dev/v1beta1 +appVersion: 3.4.10 +kubernetesVersion: 1.15.0 +kudoVersion: 0.2.0 +maintainers: +- email: avarkockova@mesosphere.com + name: Alena Varkockova +- email: runyontr@gmail.com + name: Tom Runyon +- email: kensipe@gmail.com + name: Ken Sipe +name: zookeeper +plans: + deploy: + phases: + - name: zookeeper + steps: + - name: everything + tasks: + - infra + - app + strategy: parallel + strategy: serial + flush: + phases: + - name: flush + steps: + - name: push-lever + tasks: + - lower-lever + strategy: serial + strategy: serial + validation: + phases: + - name: connection + steps: + - name: connection + tasks: + - validation + strategy: parallel + strategy: serial +tasks: +- kind: Apply + name: infra + spec: + resources: + - services.yaml + - pdb.yaml +- kind: Apply + name: app + spec: + resources: + - statefulset.yaml +- kind: Apply + name: validation + spec: + resources: + - validation.yaml +url: https://zookeeper.apache.org/ +version: 0.1.0 diff --git a/pkg/kudoctl/cmd/generate/testdata/task.golden b/pkg/kudoctl/cmd/generate/testdata/task.golden new file mode 100644 index 000000000..83b3b2a4b --- /dev/null +++ b/pkg/kudoctl/cmd/generate/testdata/task.golden @@ -0,0 +1,56 @@ +apiVersion: kudo.dev/v1beta1 +appVersion: 3.4.10 +kubernetesVersion: 1.15.0 +kudoVersion: 0.2.0 +maintainers: +- email: avarkockova@mesosphere.com + name: Alena Varkockova +- email: runyontr@gmail.com + name: Tom Runyon +- email: kensipe@gmail.com + name: Ken Sipe +name: zookeeper +plans: + deploy: + phases: + - name: zookeeper + steps: + - name: everything + tasks: + - infra + - app + strategy: parallel + strategy: serial + validation: + phases: + - name: connection + steps: + - name: connection + tasks: + - validation + strategy: parallel + strategy: serial +tasks: +- kind: Apply + name: infra + spec: + resources: + - services.yaml + - pdb.yaml +- kind: Apply + name: app + spec: + resources: + - statefulset.yaml +- kind: Apply + name: validation + spec: + resources: + - validation.yaml +- kind: Apply + name: Foo + spec: + resources: + - bar.yaml +url: https://zookeeper.apache.org/ +version: 0.1.0 diff --git a/pkg/kudoctl/cmd/package.go b/pkg/kudoctl/cmd/package.go index f84699ca3..b94117695 100644 --- a/pkg/kudoctl/cmd/package.go +++ b/pkg/kudoctl/cmd/package.go @@ -17,6 +17,7 @@ provide a list of parameters from a remote operator given a url or repository al const packageExamples = ` kubectl kudo package create [operator folder] kubectl kudo package params list [operator] kubectl kudo package verify [operator] + kubectl kudo package add [subcommand] ` // newPackageCmd for operator commands such as packaging an operator or retrieving it's parameters @@ -28,6 +29,7 @@ func newPackageCmd(fs afero.Fs, out io.Writer) *cobra.Command { Example: packageExamples, } + cmd.AddCommand(newPackageAddCmd(fs, out)) cmd.AddCommand(newPackageCreateCmd(fs, out)) cmd.AddCommand(newPackageNewCmd(fs, out)) cmd.AddCommand(newPackageParamsCmd(fs, out)) diff --git a/pkg/kudoctl/cmd/package_add.go b/pkg/kudoctl/cmd/package_add.go new file mode 100644 index 000000000..82aff77d7 --- /dev/null +++ b/pkg/kudoctl/cmd/package_add.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "io" + + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +const packageAddDesc = ` +This command consists of multiple sub-commands to interact with KUDO packages. These commands are used in the creation +or updating of an operator by an operator developer. + +It can be used to add parameters, tasks and maintainers. It is expected to be used inside an operator package or optional +can be used with the operator as a sub folder (such as a folder named "operator"). The sub folder name doesn't matter as +long as there is only one sub folder with an operator.yaml file. +` + +const packageAddExamples = ` kubectl kudo package add parameter + kubectl kudo package add maintainer + kubectl kudo package add task +` + +// newPackageAddCmd for operator commands such as adding parameters or maintainers to a package +func newPackageAddCmd(fs afero.Fs, out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "add ", + Short: "add content to an operator package files", + Long: packageAddDesc, + Example: packageAddExamples, + } + + cmd.AddCommand(newPackageAddMaintainerCmd(fs, out)) + cmd.AddCommand(newPackageAddParameterCmd(fs, out)) + cmd.AddCommand(newPackageAddTaskCmd(fs, out)) + cmd.AddCommand(newPackageAddPlanCmd(fs, out)) + + return cmd +} diff --git a/pkg/kudoctl/cmd/package_add_maintainer.go b/pkg/kudoctl/cmd/package_add_maintainer.go new file mode 100644 index 000000000..ee8671968 --- /dev/null +++ b/pkg/kudoctl/cmd/package_add_maintainer.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "errors" + "io" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/generate" + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/prompt" +) + +const ( + pkgAddMaintainerDesc = `Adds a maintainer to existing operator package files. +` + pkgAddMaintainerExample = ` kubectl kudo package add maintainer + +# Specify a destination folder other than current working directory + kubectl kudo package add maintainer ` +) + +type packageAddMaintainerCmd struct { + path string + interactive bool + out io.Writer + fs afero.Fs +} + +// newPackageAddMaintainerCmd adds a maintainer to an exist operator package +func newPackageAddMaintainerCmd(fs afero.Fs, out io.Writer) *cobra.Command { + + pkg := &packageAddMaintainerCmd{out: out, fs: fs} + cmd := &cobra.Command{ + Use: "maintainer", + Short: "adds a maintainer to the operator.yaml file", + Long: pkgAddMaintainerDesc, + Example: pkgAddMaintainerExample, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateAddMaintainerArg(args); err != nil { + return err + } + checkMode(pkg, args) + path, err := generate.OperatorPath(fs) + if err != nil { + return err + } + pkg.path = path + if err := pkg.run(args); err != nil { + return err + } + return nil + }, + } + + f := cmd.Flags() + f.BoolVarP(&pkg.interactive, "interactive", "i", false, "Interactive mode.") + return cmd +} + +func checkMode(pkg *packageAddMaintainerCmd, args []string) { + pkg.interactive = len(args) == 0 +} + +// valid options are 0 (interactive mode) or 2 +func validateAddMaintainerArg(args []string) error { + if len(args) == 1 || len(args) > 2 { + return errors.New("expecting two arguments - name and email address") + } + return nil +} + +// run returns the errors associated with cmd env +func (pkg *packageAddMaintainerCmd) run(args []string) error { + + if !pkg.interactive { + m := v1beta1.Maintainer{Name: args[0], Email: args[1]} + return generate.AddMaintainer(pkg.fs, pkg.path, &m) + } + // interactive mode + m, err := prompt.ForMaintainer() + if err != nil { + return err + } + + return generate.AddMaintainer(pkg.fs, pkg.path, m) +} diff --git a/pkg/kudoctl/cmd/package_add_parameter.go b/pkg/kudoctl/cmd/package_add_parameter.go new file mode 100644 index 000000000..af374db20 --- /dev/null +++ b/pkg/kudoctl/cmd/package_add_parameter.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "io" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/generate" + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/prompt" +) + +const ( + pkgAddParameterDesc = `Adds a parameter to existing operator package files. +` + pkgAddParameterExample = ` kubectl kudo package add parameter +` +) + +type packageAddParameterCmd struct { + path string + interactive bool + out io.Writer + fs afero.Fs +} + +// newPackageAddParameterCmd adds a parameter to an exist operator params.yaml file +func newPackageAddParameterCmd(fs afero.Fs, out io.Writer) *cobra.Command { + + pkg := &packageAddParameterCmd{out: out, fs: fs} + cmd := &cobra.Command{ + Use: "parameter", + Short: "adds a parameter to the params.yaml file", + Long: pkgAddParameterDesc, + Example: pkgAddParameterExample, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := generate.OperatorPath(fs) + if err != nil { + return err + } + pkg.path = path + if err := pkg.run(); err != nil { + return err + } + return nil + }, + } + + f := cmd.Flags() + f.BoolVarP(&pkg.interactive, "interactive", "i", false, "Interactive mode.") + return cmd +} + +func (pkg *packageAddParameterCmd) run() error { + + // interactive mode + planNames, err := generate.PlanNameList(pkg.fs, pkg.path) + if err != nil { + return err + } + + paramNames, err := generate.ParameterNameList(pkg.fs, pkg.path) + if err != nil { + return err + } + + param, err := prompt.ForParameter(planNames, paramNames) + if err != nil { + return err + } + + return generate.AddParameter(pkg.fs, pkg.path, param) +} diff --git a/pkg/kudoctl/cmd/package_add_plan.go b/pkg/kudoctl/cmd/package_add_plan.go new file mode 100644 index 000000000..0cdf51b22 --- /dev/null +++ b/pkg/kudoctl/cmd/package_add_plan.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "io" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/generate" + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/prompt" +) + +const ( + pkgAddPlanDesc = `Adds a plan to existing operator package files. +` + pkgAddPlanExample = ` kubectl kudo package add plan +` +) + +type packageAddPlanCmd struct { + path string + interactive bool + out io.Writer + fs afero.Fs +} + +// newPackageAddPlanCmd adds a plan to an exist operator package +func newPackageAddPlanCmd(fs afero.Fs, out io.Writer) *cobra.Command { + + pkg := &packageAddPlanCmd{out: out, fs: fs} + cmd := &cobra.Command{ + Use: "plan", + Short: "adds a plan to the operator.yaml file", + Long: pkgAddPlanDesc, + Example: pkgAddPlanExample, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := generate.OperatorPath(fs) + if err != nil { + return err + } + pkg.path = path + if err := pkg.run(); err != nil { + return err + } + return nil + }, + } + + f := cmd.Flags() + f.BoolVarP(&pkg.interactive, "interactive", "i", false, "Interactive mode.") + return cmd +} + +func (pkg *packageAddPlanCmd) run() error { + + planNames, err := generate.PlanNameList(pkg.fs, pkg.path) + if err != nil { + return err + } + // get list of tasks + tasks, err := generate.TaskList(pkg.fs, pkg.path) + if err != nil { + return err + } + + // interactive mode + planName, plan, err := prompt.ForPlan(planNames, tasks, pkg.fs, pkg.path, createTaskFromPrompts) + if err != nil { + return err + } + + return generate.AddPlan(pkg.fs, pkg.path, planName, plan) +} diff --git a/pkg/kudoctl/cmd/package_add_task.go b/pkg/kudoctl/cmd/package_add_task.go new file mode 100644 index 000000000..ffeb243eb --- /dev/null +++ b/pkg/kudoctl/cmd/package_add_task.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "io" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/generate" + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/prompt" +) + +const ( + pkgAddTaskDesc = `Adds a task to existing operator package files. +` + pkgAddTaskExample = ` kubectl kudo package add task +` +) + +type packageAddTaskCmd struct { + path string + interactive bool + out io.Writer + fs afero.Fs +} + +// newPackageAddTaskCmd adds a task to an exist operator package +func newPackageAddTaskCmd(fs afero.Fs, out io.Writer) *cobra.Command { + + pkg := &packageAddTaskCmd{out: out, fs: fs} + cmd := &cobra.Command{ + Use: "task", + Short: "adds a task to the operator.yaml file", + Long: pkgAddTaskDesc, + Example: pkgAddTaskExample, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := generate.OperatorPath(fs) + if err != nil { + return err + } + pkg.path = path + if err := pkg.run(); err != nil { + return err + } + return nil + }, + } + + f := cmd.Flags() + f.BoolVarP(&pkg.interactive, "interactive", "i", false, "Interactive mode.") + return cmd +} + +func (pkg *packageAddTaskCmd) run() error { + // interactive mode + existing, err := generate.TaskList(pkg.fs, pkg.path) + if err != nil { + return err + } + + taskName, err := prompt.ForTaskName(existing) + if err != nil { + return err + } + + return createTaskFromPrompts(pkg.fs, pkg.path, taskName) +} + +// createTaskFromPrompts provides sharable function for creating tasks from prompts +func createTaskFromPrompts(fs afero.Fs, path string, taskName string) error { + // interactive mode + task, err := prompt.ForTask(taskName) + if err != nil { + return err + } + + // ensure resources exist + err = generate.EnsureTaskResources(fs, path, task) + if err != nil { + return nil + } + + return generate.AddTask(fs, path, task) + +} diff --git a/pkg/kudoctl/cmd/package_new.go b/pkg/kudoctl/cmd/package_new.go index fe53a92d3..6e9034295 100644 --- a/pkg/kudoctl/cmd/package_new.go +++ b/pkg/kudoctl/cmd/package_new.go @@ -1,10 +1,8 @@ package cmd import ( - "errors" "io" - "github.com/Masterminds/semver" "github.com/spf13/afero" "github.com/spf13/cobra" @@ -18,7 +16,7 @@ import ( const ( pkgNewDesc = `Create a new KUDO operator on the local filesystem` - pkgNewExample = ` # Create a new KUDO operator name foo + pkgNewExample = ` # Create a new KUDO operator name foo kubectl kudo package new foo ` ) @@ -60,82 +58,26 @@ func newPackageNewCmd(fs afero.Fs, out io.Writer) *cobra.Command { // run returns the errors associated with cmd env func (pkg *packageNewCmd) run() error { + + // defaults pathDefault := "operator" - ovDefault := "0.1.0" - kudoDefault := version.Get().GitVersion - apiVersionDefault := reader.APIVersion + opDefault := packages.OperatorFile{ + Name: pkg.name, + APIVersion: reader.APIVersion, + Version: "0.1.0", + KUDOVersion: version.Get().GitVersion, + } if !pkg.interactive { - op := packages.OperatorFile{ - Name: pkg.name, - APIVersion: apiVersionDefault, - Version: ovDefault, - KUDOVersion: kudoDefault, - } - return generate.Operator(pkg.fs, pathDefault, op, pkg.overwrite) + return generate.Operator(pkg.fs, pathDefault, &opDefault, pkg.overwrite) } // interactive mode - nameValid := func(input string) error { - if len(input) < 3 { - return errors.New("Operator name must have more than 3 characters") - } - return nil - } - - name, err := prompt.WithValidator("Operator Name", pkg.name, nameValid) - if err != nil { - return err - } - - pathValid := func(input string) error { - if len(input) < 1 { - return errors.New("Operator directory must have more than 1 character") - } - return generate.CanGenerateOperator(pkg.fs, input, pkg.overwrite) - } - - path, err := prompt.WithValidator("Operator directory", pathDefault, pathValid) - if err != nil { - return err - } - - versionValid := func(input string) error { - if len(input) < 1 { - return errors.New("Operator version is required in semver format") - } - _, err := semver.NewVersion(input) - return err - } - opVersion, err := prompt.WithValidator("Operator Version", ovDefault, versionValid) - if err != nil { - return err - } - - appVersion, err := prompt.WithDefault("Application Version", "") + op, path, err := prompt.ForOperator(pkg.fs, pathDefault, pkg.overwrite, opDefault) if err != nil { return err } - kudoVersion, err := prompt.WithDefault("Required KUDO Version", kudoDefault) - if err != nil { - return err - } - - url, err := prompt.WithDefault("Project URL", "") - if err != nil { - return err - } - - op := packages.OperatorFile{ - Name: name, - APIVersion: apiVersionDefault, - Version: opVersion, - AppVersion: appVersion, - KUDOVersion: kudoVersion, - URL: url, - } - return generate.Operator(pkg.fs, path, op, pkg.overwrite) } diff --git a/pkg/kudoctl/cmd/package_params_list.go b/pkg/kudoctl/cmd/package_params_list.go index 944434653..b6a642df2 100644 --- a/pkg/kudoctl/cmd/package_params_list.go +++ b/pkg/kudoctl/cmd/package_params_list.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/kudobuilder/kudo/pkg/kudoctl/clog" + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/generate" "github.com/kudobuilder/kudo/pkg/kudoctl/env" "github.com/kudobuilder/kudo/pkg/kudoctl/packages" pkgresolver "github.com/kudobuilder/kudo/pkg/kudoctl/packages/resolver" @@ -43,10 +44,21 @@ func newParamsListCmd(fs afero.Fs, out io.Writer) *cobra.Command { Short: "List operator parameters", Example: pkgParamsExample, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateOperatorArg(args); err != nil { + + path, patherr := generate.OperatorPath(fs) + if patherr != nil { + clog.V(2).Printf("operator path is not relative to execution") + } else { + list.path = path + } + err := validateOperatorArg(args) + if err != nil && patherr != nil { return err } - list.path = args[0] + // if passed in... args take precedence + if err == nil { + list.path = args[0] + } return list.run(fs, &Settings) }, } diff --git a/pkg/kudoctl/cmd/prompt/operator.go b/pkg/kudoctl/cmd/prompt/operator.go new file mode 100644 index 000000000..c43dfcdb0 --- /dev/null +++ b/pkg/kudoctl/cmd/prompt/operator.go @@ -0,0 +1,486 @@ +package prompt + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/Masterminds/semver" + "github.com/spf13/afero" + + "github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1" + "github.com/kudobuilder/kudo/pkg/engine/task" + "github.com/kudobuilder/kudo/pkg/kudoctl/cmd/generate" + "github.com/kudobuilder/kudo/pkg/kudoctl/packages" +) + +// ForOperator prompts to gather details for a new operator +func ForOperator(fs afero.Fs, pathDefault string, overwrite bool, operatorDefault packages.OperatorFile) (*packages.OperatorFile, string, error) { + + nameValid := func(input string) error { + if len(input) < 3 { + return errors.New("Operator name must have more than 3 characters") + } + return nil + } + + name, err := WithValidator("Operator Name", operatorDefault.Name, nameValid) + if err != nil { + return nil, "", err + } + + pathValid := func(input string) error { + if len(input) < 1 { + return errors.New("Operator directory must have more than 1 character") + } + return generate.CanGenerateOperator(fs, input, overwrite) + } + + path, err := WithValidator("Operator directory", pathDefault, pathValid) + if err != nil { + return nil, "", err + } + + versionValid := func(input string) error { + if len(input) < 1 { + return errors.New("Operator version is required in semver format") + } + _, err := semver.NewVersion(input) + return err + } + opVersion, err := WithValidator("Operator Version", operatorDefault.Version, versionValid) + if err != nil { + return nil, "", err + } + + appVersion, err := WithDefault("Application Version", "") + if err != nil { + return nil, "", err + } + + kudoVersion, err := WithDefault("Required KUDO Version", operatorDefault.KUDOVersion) + if err != nil { + return nil, "", err + } + + url, err := WithDefault("Project URL", "") + if err != nil { + return nil, "", err + } + + op := packages.OperatorFile{ + Name: name, + APIVersion: operatorDefault.APIVersion, + Version: opVersion, + AppVersion: appVersion, + KUDOVersion: kudoVersion, + URL: url, + } + return &op, path, nil +} + +// ForMaintainer prompts to gather information to add an operator maintainer +func ForMaintainer() (*v1beta1.Maintainer, error) { + + nameValid := func(input string) error { + if len(input) < 1 { + return errors.New("Maintainer name must be > than 1 character") + } + return nil + } + + name, err := WithValidator("Maintainer Name", "", nameValid) + if err != nil { + return nil, err + } + + emailValid := func(input string) error { + if !validEmail(input) { + return errors.New("maintainer email must be valid email address") + } + return nil + } + + email, err := WithValidator("Maintainer Email", "", emailValid) + if err != nil { + return nil, err + } + + return &v1beta1.Maintainer{Name: name, Email: email}, nil +} + +func validEmail(email string) bool { + re := regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + return re.MatchString(email) +} + +// ForParameter prompts to gather information to add an operator parameter +func ForParameter(planNames []string, paramNameList []string) (*v1beta1.Parameter, error) { + nameValid := func(input string) error { + if len(input) < 1 { + return errors.New("Parameter name must be > than 1 character") + } + if inArray(input, paramNameList) { + return errors.New("Parameter name must be unique") + } + return nil + } + name, err := WithValidator("Parameter Name", "", nameValid) + if err != nil { + return nil, err + } + + value, err := WithDefault("Default Value", "") + if err != nil { + return nil, err + } + + displayName, err := WithDefault("Display Name", "") + if err != nil { + return nil, err + } + + // building param + parameter := v1beta1.Parameter{ + DisplayName: displayName, + Name: name, + } + if value != "" { + parameter.Default = &value + } + + desc, err := WithDefault("Description", "") + if err != nil { + return nil, err + } + if desc != "" { + parameter.Description = desc + } + + // order determines the default ("false" is preferred) + requiredValues := []string{"false", "true"} + required, err := WithOptions("Required", requiredValues, "") + if err != nil { + return nil, err + } + if required == "true" { + t := true + parameter.Required = &t + } + + //PlanNameList + if Confirm("Add Trigger Plan") { + var trigger string + if len(planNames) == 0 { + trigger, err = WithDefault("Trigger Plan", "") + } else { + trigger, err = WithOptions("Trigger Plan", planNames, "New plan name to trigger") + } + if err != nil { + return nil, err + } + if trigger != "" { + parameter.Trigger = trigger + } + } + + return ¶meter, nil +} + +func ForTaskName(existingTasks []v1beta1.Task) (string, error) { + nameValid := func(input string) error { + if len(input) < 1 { + return errors.New("Task name must be > than 1 character") + } + if taskExists(input, existingTasks) { + return errors.New("Task name must be unique") + } + return nil + } + name, err := WithValidator("Task Name", "", nameValid) + if err != nil { + return "", err + } + return name, nil +} + +// ForTask prompts to gather information to add new task to operator +func ForTask(name string) (*v1beta1.Task, error) { + + kind, err := WithOptions("Task Kind", generate.TaskKinds(), "") + if err != nil { + return nil, err + } + spec := v1beta1.TaskSpec{} + + switch kind { + case task.ApplyTaskKind: + fallthrough + case task.DeleteTaskKind: + var again bool + resources := []string{} + for { + resource, err := WithDefault("Task Resource", "") + if err != nil { + return nil, err + } + resources = append(resources, ensureFileExtension(resource, "yaml")) + + again = Confirm("Add another Resource") + if !again { + break + } + } + spec.ResourceTaskSpec = v1beta1.ResourceTaskSpec{Resources: resources} + + case task.PipeTaskKind: + pod, err := WithDefault("Pipe Pod File", "") + if err != nil { + return nil, err + } + var again bool + pipes := []v1beta1.PipeSpec{} + for { + file, err := WithDefault("Pipe File (internal to pod)", "") + if err != nil { + return nil, err + } + kind, err := WithOptions("Pipe Kind", []string{"ConfigMap", "Secret"}, "") + if err != nil { + return nil, err + } + key, err := WithDefault("Pipe Kind Key", "") + if err != nil { + return nil, err + } + pipes = append(pipes, v1beta1.PipeSpec{ + File: file, + Kind: kind, + Key: key, + }) + again = Confirm("Add another pipe") + if !again { + break + } + } + spec.PipeTaskSpec = v1beta1.PipeTaskSpec{ + Pod: ensureFileExtension(pod, "yaml"), + Pipe: pipes, + } + } + + t := v1beta1.Task{ + Name: name, + Kind: kind, + Spec: spec, + } + + return &t, nil +} + +func taskExists(name string, existingTasks []v1beta1.Task) bool { + for _, task := range existingTasks { + if task.Name == name { + return true + } + } + return false +} + +func ensureFileExtension(fname, ext string) string { + if strings.Contains(fname, ".") { + return fname + } + return fmt.Sprintf("%s.%s", fname, ext) +} + +func ForPlan(planNames []string, tasks []v1beta1.Task, fs afero.Fs, path string, createTaskFun func(fs afero.Fs, path string, taskName string) error) (string, *v1beta1.Plan, error) { + + // initialized to all TaskNames... tasks can be added in the process of creating a plan which will be + // added to this list in the process. + allTaskNames := []string{} + for _, task := range tasks { + allTaskNames = append(allTaskNames, task.Name) + } + + nameValid := func(input string) error { + if len(input) < 1 { + return errors.New("Plan name must be > than 1 character") + } + if inArray(input, planNames) { + return errors.New("Plan name must be unique") + } + return nil + } + var defaultPlanName, defaultPhaseName, defaultStepName, defaultTaskName string + if len(planNames) == 0 { + defaultPlanName = "deploy" + defaultPhaseName = defaultPlanName + defaultStepName = defaultPlanName + defaultTaskName = defaultPlanName + } + + name, err := WithValidator("Plan Name", defaultPlanName, nameValid) + if err != nil { + return "", nil, err + } + + planStrat, err := WithOptions("Plan strategy for phase", []string{string(v1beta1.Serial), string(v1beta1.Parallel)}, "") + if err != nil { + return "", nil, err + } + plan := v1beta1.Plan{ + Strategy: v1beta1.Ordering(planStrat), + } + + // setting up for array of phases in a plan + index := 1 + anotherPhase := false + phaseNames := []string{} + phases := []v1beta1.Phase{} + for { + phase, err := forPhase(phaseNames, index, defaultPhaseName) + if err != nil { + return "", nil, err + } + + // setting up for array of steps in a phase + stepIndex := 1 + anotherStep := false + var stepNames []string + var steps []v1beta1.Step + for { + + step, err := forStep(stepNames, stepIndex, defaultStepName) + if err != nil { + return "", nil, err + } + + stepIndex++ + stepNames = append(stepNames, step.Name) + + // setting up for array of tasks in a step + var stepTaskNames []string + taskIndex := 1 + anotherTask := false + for { + taskName, err := forStepTaskName(allTaskNames, stepTaskNames, taskIndex, step.Name, defaultTaskName) + if err != nil { + return "", nil, err + } + if !inArray(taskName, allTaskNames) { + if Confirm("Create Task Now") { + err = createTaskFun(fs, path, taskName) + if err != nil { + return "", nil, err + } + } + allTaskNames = append(allTaskNames, taskName) + } + stepTaskNames = append(stepTaskNames, taskName) + taskIndex++ + defaultTaskName = "" + anotherTask = Confirm("Add another Task") + if !anotherTask { + break + } + } + + step.Tasks = stepTaskNames + steps = append(steps, *step) + defaultStepName = "" + anotherStep = Confirm("Add another Step") + if !anotherStep { + break + } + } + phase.Steps = steps + + phases = append(phases, *phase) + index++ + defaultPhaseName = "" + anotherPhase = Confirm("Add another Phase") + if !anotherPhase { + break + } + } + plan.Phases = phases + + return name, &plan, nil + +} + +func forStepTaskName(allTaskNames []string, stepTaskNames []string, taskIndex int, name string, defaultTaskName string) (taskName string, err error) { + // reduce options of tasks to those not already for this step + taskNameOptions := subtract(allTaskNames, stepTaskNames) + // if there are no tasks OR if we are using all tasks that are defined + if len(taskNameOptions) == 0 { + // no list if there is nothing in the list + taskName, err = WithDefault(fmt.Sprintf("Task Name %v for Step %q", taskIndex, name), defaultTaskName) + } else { + taskName, err = WithOptions(fmt.Sprintf("Task Name %v for Step %q", taskIndex, name), taskNameOptions, "Add New Task") + } + return taskName, err +} + +func subtract(allTasksNames []string, currentStepTaskNames []string) (result []string) { + for _, name := range allTasksNames { + if !inArray(name, currentStepTaskNames) { + result = append(result, name) + } + } + return result +} + +func forStep(stepNames []string, stepIndex int, defaultStepName string) (*v1beta1.Step, error) { + stepNameValid := func(input string) error { + if len(input) < 1 { + return errors.New("Step name must be > than 1 character") + } + if inArray(input, stepNames) { + return errors.New("Step name must be unique in a Phase") + } + return nil + } + stepName, err := WithValidator(fmt.Sprintf("Step %v name", stepIndex), defaultStepName, stepNameValid) + if err != nil { + return nil, err + } + step := v1beta1.Step{Name: stepName} + return &step, nil +} + +func forPhase(phaseNames []string, index int, defaultPhaseName string) (*v1beta1.Phase, error) { + pnameValid := func(input string) error { + if len(input) < 1 { + return errors.New("Phase name must be > than 1 character") + } + if inArray(input, phaseNames) { + return errors.New("Phase name must be unique in plan") + } + return nil + } + pname, err := WithValidator(fmt.Sprintf("Phase %v name", index), defaultPhaseName, pnameValid) + if err != nil { + return nil, err + } + phaseStrat, err := WithOptions("Phase strategy for steps", []string{string(v1beta1.Serial), string(v1beta1.Parallel)}, "") + if err != nil { + return nil, err + } + phase := v1beta1.Phase{ + Name: pname, + Strategy: v1beta1.Ordering(phaseStrat), + } + return &phase, nil +} + +func inArray(input string, values []string) bool { + for _, name := range values { + if input == name { + return true + } + } + return false +} diff --git a/pkg/kudoctl/cmd/prompt/prompt.go b/pkg/kudoctl/cmd/prompt/prompt.go index 165db2dd3..e3e623946 100644 --- a/pkg/kudoctl/cmd/prompt/prompt.go +++ b/pkg/kudoctl/cmd/prompt/prompt.go @@ -6,24 +6,41 @@ import ( "github.com/manifoldco/promptui" ) -func WithOptions(label string, options []string) (string, error) { - index := -1 - var err error - var result string - - for index < 0 { - prompt := promptui.SelectWithAdd{ - Label: label, - Items: options, - AddLabel: "Other", +// WithOptions prompts for option selection, first element in slice is default +func WithOptions(label string, options []string, addLabel string) (string, error) { + + // addLabel allows control to add more than what is in the list + allowOther := addLabel != "" + if allowOther { + var err error + var result string + index := -1 + for index < 0 { + prompt := promptui.SelectWithAdd{ + Label: label, + Items: options, + AddLabel: addLabel, + } + + index, result, err = prompt.Run() + if index == -1 { + // lets not force reselection, just return the enter value + return strings.TrimSpace(result), nil + } } - index, result, err = prompt.Run() - if index == -1 { - options = append(options, result) + if err != nil { + return "", err } + return strings.TrimSpace(result), nil + } + + prompt := promptui.Select{ + Label: label, + Items: options, } + _, result, err := prompt.Run() if err != nil { return "", err } @@ -36,10 +53,12 @@ func cursor(input []rune) []rune { return input } +// WithDefault prompts for a response to a label func WithDefault(label string, defaultStr string) (string, error) { return WithValidator(label, defaultStr, nil) } +// WithValidator prompts for a response to a label with a validation function func WithValidator(label string, defaultStr string, validate promptui.ValidateFunc) (string, error) { prompt := promptui.Prompt{ Label: label, @@ -54,3 +73,17 @@ func WithValidator(label string, defaultStr string, validate promptui.ValidateFu } return strings.TrimSpace(result), nil } + +// Confirm prompts for Y/N question with label and returns true or false for confirmation +func Confirm(label string) bool { + prompt := promptui.Prompt{ + Label: label, + IsConfirm: true, + } + + result, err := prompt.Run() + if err != nil { + return false + } + return strings.ToLower(result) == "y" +} diff --git a/pkg/kudoctl/packages/resolver/resolver_local.go b/pkg/kudoctl/packages/resolver/resolver_local.go index 8f4c416a2..8c4c7f8f7 100644 --- a/pkg/kudoctl/packages/resolver/resolver_local.go +++ b/pkg/kudoctl/packages/resolver/resolver_local.go @@ -35,10 +35,10 @@ func (f *LocalResolver) Resolve(name string, version string) (*packages.Package, clog.V(1).Printf("determining package type of %v", name) if fi.Mode().IsRegular() && strings.HasSuffix(name, ".tgz") { - clog.V(0).Printf("%v is a local tgz package", name) + clog.V(1).Printf("%v is a local tgz package", name) return reader.ReadTar(f.fs, name) } else if fi.IsDir() { - clog.V(0).Printf("%v is a local file package", name) + clog.V(1).Printf("%v is a local file package", name) return reader.ReadDir(f.fs, name) } else { return nil, fmt.Errorf("unsupported file system format %v. Expect either a *.tgz file or a folder", name) diff --git a/pkg/kudoctl/util/repo/resolver_repo.go b/pkg/kudoctl/util/repo/resolver_repo.go index a59c85d6a..f3b15ebf3 100644 --- a/pkg/kudoctl/util/repo/resolver_repo.go +++ b/pkg/kudoctl/util/repo/resolver_repo.go @@ -16,7 +16,7 @@ func (c *Client) Resolve(name string, version string) (*packages.Package, error) if err != nil { return nil, err } - clog.V(0).Printf("%v is a repository package from %v", name, c.Config) + clog.V(2).Printf("%v is a repository package from %v", name, c.Config) files, err := reader.ParseTgz(buf) if err != nil {