diff --git a/pkg/api/v1alpha1/phaseplan_types.go b/pkg/api/v1alpha1/phaseplan_types.go index 577d49dbc..0a641f5fe 100644 --- a/pkg/api/v1alpha1/phaseplan_types.go +++ b/pkg/api/v1alpha1/phaseplan_types.go @@ -30,5 +30,6 @@ type PhasePlan struct { // PhaseStep represents phase (or step) within a phase plan type PhaseStep struct { - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` } diff --git a/pkg/phase/command.go b/pkg/phase/command.go index 5f814fccd..30d229454 100644 --- a/pkg/phase/command.go +++ b/pkg/phase/command.go @@ -28,8 +28,10 @@ import ( "opendev.org/airship/airshipctl/pkg/api/v1alpha1" "opendev.org/airship/airshipctl/pkg/config" "opendev.org/airship/airshipctl/pkg/document" + phaseerrors "opendev.org/airship/airshipctl/pkg/phase/errors" "opendev.org/airship/airshipctl/pkg/phase/ifc" "opendev.org/airship/airshipctl/pkg/util" + "opendev.org/airship/airshipctl/pkg/util/yaml" ) // GenericRunFlags generic options for run command @@ -73,14 +75,18 @@ func (c *RunCommand) RunE() error { // ListCommand phase list command type ListCommand struct { - Factory config.Factory - Writer io.Writer - ClusterName string - PlanID ifc.ID + Factory config.Factory + Writer io.Writer + ClusterName string + PlanID ifc.ID + OutputFormat string } -// RunE runs a phase plan command +// RunE runs a phase list command func (c *ListCommand) RunE() error { + if c.OutputFormat != "table" && c.OutputFormat != "yaml" { + return phaseerrors.ErrInvalidFormat{RequestedFormat: c.OutputFormat} + } cfg, err := c.Factory() if err != nil { return err @@ -92,18 +98,14 @@ func (c *ListCommand) RunE() error { } o := ifc.ListPhaseOptions{ClusterName: c.ClusterName, PlanID: c.PlanID} - phases, err := helper.ListPhases(o) + phaseList, err := helper.ListPhases(o) if err != nil { return err } - - rt, err := util.NewResourceTable(phases, util.DefaultStatusFunction()) - if err != nil { - return err + if c.OutputFormat == "table" { + return PrintPhaseListTable(c.Writer, phaseList) } - - util.DefaultTablePrinter(c.Writer, nil).PrintTable(rt, 0) - return nil + return yaml.WriteOut(c.Writer, phaseList) } // TreeCommand plan command diff --git a/pkg/phase/command_test.go b/pkg/phase/command_test.go index 04302ca76..a7b49bb73 100644 --- a/pkg/phase/command_test.go +++ b/pkg/phase/command_test.go @@ -27,6 +27,7 @@ import ( "opendev.org/airship/airshipctl/pkg/config" "opendev.org/airship/airshipctl/pkg/log" "opendev.org/airship/airshipctl/pkg/phase" + "opendev.org/airship/airshipctl/pkg/phase/ifc" ) const ( @@ -34,6 +35,8 @@ const ( testNewHelperErr = "missing configuration" testNoBundlePath = "no such file or directory" defaultCurrentContext = "context" + testTargetPath = "testdata" + testMetadataPath = "metadata.yaml" ) func TestRunCommand(t *testing.T) { @@ -67,7 +70,7 @@ func TestRunCommand(t *testing.T) { conf.Manifests = map[string]*config.Manifest{ "manifest": { MetadataPath: "broken_metadata.yaml", - TargetPath: "testdata", + TargetPath: testTargetPath, PhaseRepositoryName: config.DefaultTestPhaseRepo, Repositories: map[string]*config.Repository{ config.DefaultTestPhaseRepo: { @@ -107,18 +110,44 @@ func TestRunCommand(t *testing.T) { } func TestListCommand(t *testing.T) { + outputString1 := "NAMESPACE RESOURCE CLUSTER " + + "NAME EXECUTOR DOC ENTRYPOINT " + outputString2 := " Phase/phase ephemeral" + + "-cluster KubernetesApply ephemeral/phase " + yamlOutput := `--- +- apiVersion: airshipit.org/v1alpha1 + config: + documentEntryPoint: ephemeral/phase + executorRef: + apiVersion: airshipit.org/v1alpha1 + kind: KubernetesApply + name: kubernetes-apply + kind: Phase + metadata: + clusterName: ephemeral-cluster + creationTimestamp: null + name: phase +... +` tests := []struct { - name string - errContains string - runFlags phase.RunFlags - factory config.Factory + name string + errContains string + runFlags phase.RunFlags + expectedOut [][]byte + expectedYamlOut string + factory config.Factory + PlanID ifc.ID + PhaseID ifc.ID + OutputFormat string }{ { name: "Error config factory", factory: func() (*config.Config, error) { return nil, fmt.Errorf(testFactoryErr) }, - errContains: testFactoryErr, + errContains: testFactoryErr, + expectedOut: [][]byte{{}}, + OutputFormat: "table", }, { name: "Error new helper", @@ -128,16 +157,18 @@ func TestListCommand(t *testing.T) { Contexts: make(map[string]*config.Context), }, nil }, - errContains: testNewHelperErr, + errContains: testNewHelperErr, + expectedOut: [][]byte{{}}, + OutputFormat: "table", }, { - name: "Error phase by id", + name: "List phases", factory: func() (*config.Config, error) { conf := config.NewConfig() conf.Manifests = map[string]*config.Manifest{ "manifest": { - MetadataPath: "broken_metadata.yaml", - TargetPath: "testdata", + MetadataPath: testMetadataPath, + TargetPath: testTargetPath, PhaseRepositoryName: config.DefaultTestPhaseRepo, Repositories: map[string]*config.Repository{ config.DefaultTestPhaseRepo: { @@ -154,15 +185,55 @@ func TestListCommand(t *testing.T) { } return conf, nil }, - errContains: testNoBundlePath, + expectedOut: [][]byte{ + []byte(outputString1), + []byte(outputString2), + {}, + }, + OutputFormat: "table", + }, + { + name: "List phases of a plan", + factory: func() (*config.Config, error) { + conf := config.NewConfig() + manifest := conf.Manifests[config.AirshipDefaultManifest] + manifest.TargetPath = testTargetPath + manifest.MetadataPath = testMetadataPath + manifest.Repositories[config.DefaultTestPhaseRepo].URLString = "" + return conf, nil + }, + PlanID: ifc.ID{Name: "phasePlan"}, + expectedOut: [][]byte{ + []byte(outputString1), + []byte(outputString2), + {}, + }, + OutputFormat: "table", + }, + { + name: "List phases yaml format", + factory: func() (*config.Config, error) { + conf := config.NewConfig() + manifest := conf.Manifests[config.AirshipDefaultManifest] + manifest.TargetPath = testTargetPath + manifest.MetadataPath = testMetadataPath + manifest.Repositories[config.DefaultTestPhaseRepo].URLString = "" + return conf, nil + }, + OutputFormat: "yaml", + expectedYamlOut: yamlOutput, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { + buffer := &bytes.Buffer{} command := phase.ListCommand{ - Factory: tt.factory, + Factory: tt.factory, + Writer: buffer, + PlanID: tt.PlanID, + OutputFormat: tt.OutputFormat, } err := command.RunE() if tt.errContains != "" { @@ -171,6 +242,14 @@ func TestListCommand(t *testing.T) { } else { assert.NoError(t, err) } + out, err := ioutil.ReadAll(buffer) + require.NoError(t, err) + if tt.OutputFormat == "yaml" { + assert.Equal(t, tt.expectedYamlOut, string(out)) + } else { + b := bytes.Split(out, []byte("\n")) + assert.Equal(t, tt.expectedOut, b) + } }) } } @@ -205,7 +284,7 @@ func TestTreeCommand(t *testing.T) { conf.Manifests = map[string]*config.Manifest{ "manifest": { MetadataPath: "broken_metadata.yaml", - TargetPath: "testdata", + TargetPath: testTargetPath, PhaseRepositoryName: config.DefaultTestPhaseRepo, Repositories: map[string]*config.Repository{ config.DefaultTestPhaseRepo: { @@ -264,7 +343,7 @@ func TestPlanListCommand(t *testing.T) { conf := config.NewConfig() manifest := conf.Manifests[config.AirshipDefaultManifest] manifest.TargetPath = "testdata" - manifest.MetadataPath = "metadata.yaml" + manifest.MetadataPath = testMetadataPath manifest.Repositories[config.DefaultTestPhaseRepo].URLString = "" return conf, nil }, @@ -328,7 +407,7 @@ func TestPlanRunCommand(t *testing.T) { conf := config.NewConfig() conf.Manifests = map[string]*config.Manifest{ "manifest": { - MetadataPath: "metadata.yaml", + MetadataPath: testMetadataPath, TargetPath: "testdata", PhaseRepositoryName: config.DefaultTestPhaseRepo, Repositories: map[string]*config.Repository{ diff --git a/pkg/phase/errors/errors.go b/pkg/phase/errors/errors.go index bd506ec6e..499c23ad2 100644 --- a/pkg/phase/errors/errors.go +++ b/pkg/phase/errors/errors.go @@ -52,6 +52,15 @@ func (e ErrRenderPhaseNameNotSpecified) Error() string { e.Sources) } +// ErrInvalidFormat is called when the user provides format other than yaml/json +type ErrInvalidFormat struct { + RequestedFormat string +} + +func (e ErrInvalidFormat) Error() string { + return fmt.Sprintf("invalid output format specified %s. Allowed values are table|yaml", e.RequestedFormat) +} + // ErrInvalidPhase is returned if the phase is invalid type ErrInvalidPhase struct { Reason string diff --git a/pkg/phase/helper_test.go b/pkg/phase/helper_test.go index ddb31a321..d5c82901f 100644 --- a/pkg/phase/helper_test.go +++ b/pkg/phase/helper_test.go @@ -110,11 +110,13 @@ func TestHelperPlan(t *testing.T) { name string errContains string expectedPlan *v1alpha1.PhasePlan + planID ifc.ID config func(t *testing.T) *config.Config }{ { name: "Valid Phase Plan", config: testConfig, + planID: ifc.ID{Name: "phasePlan"}, expectedPlan: &airshipv1.PhasePlan{ TypeMeta: metav1.TypeMeta{ Kind: "PhasePlan", @@ -188,7 +190,7 @@ func TestHelperListPhases(t *testing.T) { }{ { name: "Success phase list", - phaseLen: 5, + phaseLen: 8, config: testConfig, }, { diff --git a/pkg/phase/printers.go b/pkg/phase/printers.go new file mode 100644 index 000000000..1a3a5712d --- /dev/null +++ b/pkg/phase/printers.go @@ -0,0 +1,88 @@ +/* + 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 + + https://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 phase + +import ( + "errors" + "fmt" + "io" + + "k8s.io/apimachinery/pkg/runtime" + + "opendev.org/airship/airshipctl/pkg/api/v1alpha1" + "opendev.org/airship/airshipctl/pkg/util" + + "sigs.k8s.io/cli-utils/pkg/print/table" +) + +//PrintPhaseListTable prints phase list table +func PrintPhaseListTable(w io.Writer, phases []*v1alpha1.Phase) error { + rt, err := util.NewResourceTable(phases, util.DefaultStatusFunction()) + if err != nil { + return err + } + printer := util.DefaultTablePrinter(w, nil) + clusternameCol := table.ColumnDef{ + ColumnName: "clustername", + ColumnHeader: "CLUSTER NAME", + ColumnWidth: 20, + PrintResourceFunc: func(w io.Writer, width int, r table.Resource) (int, + error) { + phase, err := phaseFromResource(r) + if err != nil { + return 0, nil + } + return fmt.Fprintf(w, phase.ClusterName) + }, + } + executorrefkindCol := table.ColumnDef{ + ColumnName: "executorrefkind", + ColumnHeader: "EXECUTOR", + ColumnWidth: 20, + PrintResourceFunc: func(w io.Writer, width int, r table.Resource) (int, + error) { + phase, err := phaseFromResource(r) + if err != nil { + return 0, nil + } + return fmt.Fprintf(w, phase.Config.ExecutorRef.Kind) + }, + } + docentrypointCol := table.ColumnDef{ + ColumnName: "docentrypoint", + ColumnHeader: "DOC ENTRYPOINT", + ColumnWidth: 40, + PrintResourceFunc: func(w io.Writer, width int, r table.Resource) (int, + error) { + phase, err := phaseFromResource(r) + if err != nil { + return 0, nil + } + return fmt.Fprintf(w, phase.Config.DocumentEntryPoint) + }, + } + printer.Columns = append(printer.Columns, clusternameCol, executorrefkindCol, docentrypointCol) + printer.PrintTable(rt, 0) + return nil +} + +func phaseFromResource(r table.Resource) (*v1alpha1.Phase, error) { + rs := r.ResourceStatus() + if rs == nil { + return nil, errors.New("Resource status is nil") + } + phase := &v1alpha1.Phase{} + return phase, runtime.DefaultUnstructuredConverter.FromUnstructured(rs.Resource.Object, phase) +} diff --git a/pkg/phase/printers_test.go b/pkg/phase/printers_test.go new file mode 100644 index 000000000..317000af9 --- /dev/null +++ b/pkg/phase/printers_test.go @@ -0,0 +1,120 @@ +/* + 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 + + https://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 phase + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "opendev.org/airship/airshipctl/pkg/api/v1alpha1" + "opendev.org/airship/airshipctl/pkg/util" +) + +func TestPrintPhaseListTable(t *testing.T) { + phases := []*v1alpha1.Phase{ + { + TypeMeta: metav1.TypeMeta{ + Kind: "Phase", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "p1", + ClusterName: "cluster", + }, + Config: v1alpha1.PhaseConfig{ + DocumentEntryPoint: "test", + ExecutorRef: &v1.ObjectReference{Kind: "test"}, + }, + }, + } + + tests := []struct { + name string + phases []*v1alpha1.Phase + wantPanic bool + }{ + { + name: "success", + phases: phases, + wantPanic: false, + }, + { + name: "phase with no executor ref", + phases: []*v1alpha1.Phase{ + { + TypeMeta: metav1.TypeMeta{ + Kind: "Pe", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "p1", + }, + }, + }, + wantPanic: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &bytes.Buffer{} + defer func() { + r := recover() + if (r != nil) != tt.wantPanic { + t.Errorf("should have panic") + } + }() + err := PrintPhaseListTable(w, tt.phases) + require.NoError(t, err) + }) + } +} + +func TestNonPrintable(t *testing.T) { + _, err := util.NewResourceTable("non Printable string", util.DefaultStatusFunction()) + assert.Error(t, err) +} + +func TestDefaultStatusFunction(t *testing.T) { + f := util.DefaultStatusFunction() + expectedObj := map[string]interface{}{ + "kind": "Phase", + "metadata": map[string]interface{}{ + "name": "p1", + "creationTimestamp": nil, + }, + "config": map[string]interface{}{ + "documentEntryPoint": "", + "executorRef": map[string]interface{}{ + "kind": "test", + }, + }, + } + printable := &v1alpha1.Phase{ + TypeMeta: metav1.TypeMeta{ + Kind: "Phase", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "p1", + }, + Config: v1alpha1.PhaseConfig{ + ExecutorRef: &v1.ObjectReference{Kind: "test"}, + }, + } + rs := f(printable) + assert.Equal(t, expectedObj, rs.Resource.Object) +} diff --git a/pkg/phase/testdata/valid_site/phases/kustomization.yaml b/pkg/phase/testdata/valid_site/phases/kustomization.yaml index d9edc4063..cde52b834 100644 --- a/pkg/phase/testdata/valid_site/phases/kustomization.yaml +++ b/pkg/phase/testdata/valid_site/phases/kustomization.yaml @@ -1,4 +1,5 @@ resources: + - phases.yaml - phaseplan.yaml - some_phase.yaml - some_exc.yaml diff --git a/pkg/phase/testdata/valid_site/phases/phases.yaml b/pkg/phase/testdata/valid_site/phases/phases.yaml new file mode 100644 index 000000000..c6cc8a1da --- /dev/null +++ b/pkg/phase/testdata/valid_site/phases/phases.yaml @@ -0,0 +1,32 @@ +apiVersion: airshipit.org/v1alpha1 +kind: Phase +metadata: + name: isogen +config: + executorRef: + apiVersion: airshipit.org/v1alpha1 + kind: Clusterctl + name: clusterctl-v1 + documentEntryPoint: valid_site/phases +--- +apiVersion: airshipit.org/v1alpha1 +kind: Phase +metadata: + name: remotedirect +config: + executorRef: + apiVersion: airshipit.org/v1alpha1 + kind: Clusterctl + name: clusterctl-v1 + documentEntryPoint: valid_site/phases +--- +apiVersion: airshipit.org/v1alpha1 +kind: Phase +metadata: + name: initinfra +config: + executorRef: + apiVersion: airshipit.org/v1alpha1 + kind: Clusterctl + name: clusterctl-v1 + documentEntryPoint: valid_site/phases