From 2c602833f9925868f85d24fa419b73e62e91925d Mon Sep 17 00:00:00 2001 From: mohit-bhandari45 Date: Tue, 8 Sep 2026 12:28:03 +0530 Subject: [PATCH 1/5] feat(cmd): add get subcommand for build and buildrun resources Signed-off-by: mohit-bhandari45 --- pkg/shp/cmd/build/build.go | 1 + pkg/shp/cmd/build/get.go | 119 +++++++++++++++++ pkg/shp/cmd/build/get_test.go | 220 +++++++++++++++++++++++++++++++ pkg/shp/cmd/buildrun/buildrun.go | 1 + pkg/shp/cmd/buildrun/get.go | 121 +++++++++++++++++ pkg/shp/cmd/buildrun/get_test.go | 178 +++++++++++++++++++++++++ 6 files changed, 640 insertions(+) create mode 100644 pkg/shp/cmd/build/get.go create mode 100644 pkg/shp/cmd/build/get_test.go create mode 100644 pkg/shp/cmd/buildrun/get.go create mode 100644 pkg/shp/cmd/buildrun/get_test.go diff --git a/pkg/shp/cmd/build/build.go b/pkg/shp/cmd/build/build.go index 80c1f6243..e9dfb4c71 100644 --- a/pkg/shp/cmd/build/build.go +++ b/pkg/shp/cmd/build/build.go @@ -23,6 +23,7 @@ func Command(p *params.Params, ioStreams *genericclioptions.IOStreams) *cobra.Co // TODO: add support for `update` and `get` commands command.AddCommand( + runner.NewRunner(p, ioStreams, getCmd()).Cmd(), runner.NewRunner(p, ioStreams, createCmd()).Cmd(), runner.NewRunner(p, ioStreams, listCmd()).Cmd(), runner.NewRunner(p, ioStreams, deleteCmd()).Cmd(), diff --git a/pkg/shp/cmd/build/get.go b/pkg/shp/cmd/build/get.go new file mode 100644 index 000000000..8053a76d1 --- /dev/null +++ b/pkg/shp/cmd/build/get.go @@ -0,0 +1,119 @@ +package build // nolint:revive + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + + "github.com/shipwright-io/cli/pkg/shp/cmd/runner" + "github.com/shipwright-io/cli/pkg/shp/params" +) + +type GetCommand struct { + cmd *cobra.Command + name string + output string +} + +func (c *GetCommand) Cmd() *cobra.Command { + return c.cmd +} + +func (c *GetCommand) Complete(_ *params.Params, _ *genericclioptions.IOStreams, args []string) error { + c.name = args[0] + return nil +} + +func (c *GetCommand) Validate() error { + if c.name == "" { + return fmt.Errorf("name must be provided") + } + return nil +} + +func (c *GetCommand) Run(params *params.Params, ioStreams *genericclioptions.IOStreams) error { + clientset, err := params.ShipwrightClientSet() + if err != nil { + return err + } + + ns := params.Namespace() + build, err := clientset.ShipwrightV1beta1().Builds(ns).Get(c.cmd.Context(), c.name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + fmt.Fprintf(ioStreams.Out, "Build '%s' not found in namespace '%s'.\n", c.name, ns) + return nil + } + return err + } + + switch c.output { + case "json": + data, err := json.MarshalIndent(build, "", " "); + if err != nil { + return err + } + fmt.Fprintln(ioStreams.Out, string(data)) + return nil + case "yaml": + data, err := yaml.Marshal(build); + if err != nil { + return err; + } + fmt.Fprintln(ioStreams.Out, string(data)) + return nil + case "": + w := tabwriter.NewWriter(ioStreams.Out, 0, 8, 2, '\t', 0); + fmt.Fprintf(w, "NAME:\t%s\n", build.Name) + fmt.Fprintf(w, "NAMESPACE:\t%s\n", build.Namespace) + if build.Spec.Source.Git != nil { + fmt.Fprintf(w, "SOURCE URL:\t%s\n", build.Spec.Source.Git.URL) + if build.Spec.Source.Git.Revision != nil { + fmt.Fprintf(w, "REVISION:\t%s\n", *build.Spec.Source.Git.Revision) + } + } + if build.Spec.Strategy.Name != "" { + kind := "" + if build.Spec.Strategy.Kind != nil { + kind = string(*build.Spec.Strategy.Kind) + } + if kind != "" { + fmt.Fprintf(w, "STRATEGY:\t%s (%s)\n", build.Spec.Strategy.Name, kind) + } else { + fmt.Fprintf(w, "STRATEGY:\t%s\n", build.Spec.Strategy.Name) + } + } + if build.Spec.Output.Image != "" { + fmt.Fprintf(w, "OUTPUT IMAGE:\t%s\n", build.Spec.Output.Image) + } + if build.Status.Registered != nil { + fmt.Fprintf(w, "REGISTERED:\t%s\n", *build.Status.Registered) + } + + return w.Flush() + default: + return fmt.Errorf("unsupported output format %q. Supported formats are: json, yaml", c.output) + } +} + +func getCmd() runner.SubCommand { + cmd := &cobra.Command{ + Use: "get [flags]", + Short: "Get details of a build", + Args: cobra.ExactArgs(1), + } + + c := &GetCommand{ + cmd: cmd, + } + + cmd.Flags().StringVarP(&c.output, "output", "o", "", "Output format. Allowed values: json, yaml") + return c +} \ No newline at end of file diff --git a/pkg/shp/cmd/build/get_test.go b/pkg/shp/cmd/build/get_test.go new file mode 100644 index 000000000..35f1fb372 --- /dev/null +++ b/pkg/shp/cmd/build/get_test.go @@ -0,0 +1,220 @@ +package build // nolint:revive + +import ( + "strings" + "testing" + "time" + + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + shpfake "github.com/shipwright-io/build/pkg/client/clientset/versioned/fake" + "github.com/shipwright-io/cli/pkg/shp/params" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + kclientsetfake "k8s.io/client-go/kubernetes/fake" +) + +func TestBuildGet_DefaultTable(t *testing.T) { + revision := "main" + strategyKind := buildv1beta1.ClusterBuildStrategyKind + testBuild := &buildv1beta1.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-build", + Namespace: metav1.NamespaceDefault, + }, + Spec: buildv1beta1.BuildSpec{ + Source: &buildv1beta1.Source{ + Git: &buildv1beta1.Git{ + URL: "https://github.com/shipwright-io/sample-go", + Revision: &revision, + }, + }, + Strategy: buildv1beta1.Strategy{ + Name: "buildpacks-v3", + Kind: &strategyKind, + }, + Output: buildv1beta1.Image{ + Image: "quay.io/myuser/my-app:latest", + }, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuild); + k8sClientset := kclientsetfake.NewSimpleClientset(); + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true); + timeout := 10 * time.Second; + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout); + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String(); + expectedStrings := []string{ + "NAME:", + "my-build", + "NAMESPACE:", + "default", + "SOURCE URL:", + "https://github.com/shipwright-io/sample-go", + "REVISION:", + "main", + "STRATEGY:", + "buildpacks-v3 (ClusterBuildStrategy)", + "OUTPUT IMAGE:", + "quay.io/myuser/my-app:latest", + } + + for _, expected := range expectedStrings { + if !strings.Contains(output, expected){ + t.Errorf("expected output to contain %q, but got:\n%s", expected, output) + } + } +} + +func TestBuildGet_JSON(t *testing.T) { + testBuild := &buildv1beta1.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-build", + Namespace: metav1.NamespaceDefault, + }, + Spec: buildv1beta1.BuildSpec{ + Output: buildv1beta1.Image{ + Image: "quay.io/myuser/my-app:latest", + }, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuild) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + // Set output flag to "json" + if err := cmd.Cmd().Flags().Set("output", "json"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + if !strings.Contains(output, `"name": "my-build"`) || !strings.Contains(output, `"image": "quay.io/myuser/my-app:latest"`) { + t.Errorf("expected JSON output containing build details, but got:\n%s", output) + } +} + +func TestBuildGet_YAML(t *testing.T) { + testBuild := &buildv1beta1.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-build", + Namespace: metav1.NamespaceDefault, + }, + Spec: buildv1beta1.BuildSpec{ + Output: buildv1beta1.Image{ + Image: "quay.io/myuser/my-app:latest", + }, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuild) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + // Set output flag to "yaml" + if err := cmd.Cmd().Flags().Set("output", "yaml"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + if !strings.Contains(output, "name: my-build") || !strings.Contains(output, "image: quay.io/myuser/my-app:latest") { + t.Errorf("expected YAML output containing build details, but got:\n%s", output) + } +} + +func TestBuildGet_NotFound(t *testing.T) { + shpClientset := shpfake.NewSimpleClientset(); + k8sClientSet := kclientsetfake.NewSimpleClientset(); + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second; + p := params.NewParamsForTest(k8sClientSet, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout); + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"nonexistent-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + output := out.String() + expectedMsg := "Build 'nonexistent-build' not found in namespace 'default'." + if !strings.Contains(output, expectedMsg) { + t.Errorf("expected output to contain %q, but got:\n%s", expectedMsg, output) + } +} + +func TestBuildGet_InvalidOutput(t *testing.T) { + testBuild := &buildv1beta1.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-build", + Namespace: metav1.NamespaceDefault, + }, + } + shpClientset := shpfake.NewSimpleClientset(testBuild) + k8sClientset := kclientsetfake.NewSimpleClientset() + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + if err := cmd.Complete(p, &ioStreams, []string{"my-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + // Set invalid output flag + if err := cmd.Cmd().Flags().Set("output", "invalid"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + err := cmd.Run(p, &ioStreams) + if err == nil { + t.Fatalf("expected error for unsupported output format, but got nil") + } + expectedErr := `unsupported output format "invalid". Supported formats are: json, yaml` + if err.Error() != expectedErr { + t.Errorf("expected error %q, but got %q", expectedErr, err.Error()) + } +} \ No newline at end of file diff --git a/pkg/shp/cmd/buildrun/buildrun.go b/pkg/shp/cmd/buildrun/buildrun.go index 01983214f..282361f08 100644 --- a/pkg/shp/cmd/buildrun/buildrun.go +++ b/pkg/shp/cmd/buildrun/buildrun.go @@ -22,6 +22,7 @@ func Command(p *params.Params, ioStreams *genericclioptions.IOStreams) *cobra.Co // TODO: add support for `update` and `get` commands command.AddCommand( + runner.NewRunner(p, ioStreams, getCmd()).Cmd(), runner.NewRunner(p, ioStreams, listCmd()).Cmd(), runner.NewRunner(p, ioStreams, logsCmd()).Cmd(), runner.NewRunner(p, ioStreams, createCmd()).Cmd(), diff --git a/pkg/shp/cmd/buildrun/get.go b/pkg/shp/cmd/buildrun/get.go new file mode 100644 index 000000000..19fb68181 --- /dev/null +++ b/pkg/shp/cmd/buildrun/get.go @@ -0,0 +1,121 @@ +package buildrun + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/cli/pkg/shp/cmd/runner" + "github.com/shipwright-io/cli/pkg/shp/params" +) + +// GetCommand contains user input for the `get` subcommand of BuildRun +type GetCommand struct { + cmd *cobra.Command + name string + output string +} + +func (c *GetCommand) Cmd() *cobra.Command { + return c.cmd +} + +func (c *GetCommand) Complete(_ *params.Params, _ *genericclioptions.IOStreams, args []string) error { + c.name = args[0] + return nil +} + +func (c *GetCommand) Validate() error { + if c.name == "" { + return fmt.Errorf("name must be provided") + } + return nil +} + +func (c *GetCommand) Run(params *params.Params, ioStreams *genericclioptions.IOStreams) error { + clientset, err := params.ShipwrightClientSet() + if err != nil { + return err + } + + ns := params.Namespace() + buildRun, err := clientset.ShipwrightV1beta1().BuildRuns(ns).Get(c.cmd.Context(), c.name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + fmt.Fprintf(ioStreams.Out, "BuildRun '%s' not found in namespace '%s'.\n", c.name, ns) + return nil + } + return err + } + + switch c.output { + case "json": + data, err := json.MarshalIndent(buildRun, "", " ") + if err != nil { + return err + } + fmt.Fprintln(ioStreams.Out, string(data)) + return nil + + case "yaml": + data, err := yaml.Marshal(buildRun) + if err != nil { + return err + } + fmt.Fprintln(ioStreams.Out, string(data)) + return nil + + case "": + w := tabwriter.NewWriter(ioStreams.Out, 0, 8, 2, '\t', 0) + fmt.Fprintf(w, "NAME:\t%s\n", buildRun.Name) + fmt.Fprintf(w, "NAMESPACE:\t%s\n", buildRun.Namespace) + if buildName := buildRun.Spec.BuildName(); buildName != "" { + fmt.Fprintf(w, "BUILD NAME:\t%s\n", buildName) + } + + status := "Unknown" + for _, condition := range buildRun.Status.Conditions { + if condition.Type == buildv1beta1.Succeeded { + status = condition.Reason + break + } + } + fmt.Fprintf(w, "STATUS:\t%s\n", status) + + if buildRun.Status.StartTime != nil { + fmt.Fprintf(w, "START TIME:\t%s\n", buildRun.Status.StartTime.Time.Format(time.RFC3339)) + } + if buildRun.Status.CompletionTime != nil { + fmt.Fprintf(w, "COMPLETION TIME:\t%s\n", buildRun.Status.CompletionTime.Time.Format(time.RFC3339)) + } + + return w.Flush() + + default: + return fmt.Errorf("unsupported output format %q. Supported formats are: json, yaml", c.output) + } +} + +func getCmd() runner.SubCommand { + cmd := &cobra.Command{ + Use: "get [flags]", + Short: "Get details of a BuildRun", + Args: cobra.ExactArgs(1), + } + + c := &GetCommand{ + cmd: cmd, + } + + cmd.Flags().StringVarP(&c.output, "output", "o", "", "Output format. Allowed values: json, yaml") + return c +} \ No newline at end of file diff --git a/pkg/shp/cmd/buildrun/get_test.go b/pkg/shp/cmd/buildrun/get_test.go new file mode 100644 index 000000000..53e65cd81 --- /dev/null +++ b/pkg/shp/cmd/buildrun/get_test.go @@ -0,0 +1,178 @@ +package buildrun + +import ( + "strings" + "testing" + "time" + + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + shpfake "github.com/shipwright-io/build/pkg/client/clientset/versioned/fake" + "github.com/shipwright-io/cli/pkg/shp/params" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + kclientsetfake "k8s.io/client-go/kubernetes/fake" +) + +func TestBuildRunGet_DefaultTable(t *testing.T) { + now := metav1.Now() + testBuildRun := &buildv1beta1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-buildrun", + Namespace: metav1.NamespaceDefault, + }, + Spec: buildv1beta1.BuildRunSpec{ + Build: buildv1beta1.ReferencedBuild{ + Name: ptr("my-build"), + }, + }, + Status: buildv1beta1.BuildRunStatus{ + StartTime: &now, + CompletionTime: &now, + Conditions: []buildv1beta1.Condition{ + { + Type: buildv1beta1.Succeeded, + Reason: "Succeeded", + }, + }, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuildRun) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-buildrun"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + expectedStrings := []string{ + "NAME:", + "my-buildrun", + "NAMESPACE:", + "default", + "BUILD NAME:", + "my-build", + "STATUS:", + "Succeeded", + } + + for _, expected := range expectedStrings { + if !strings.Contains(output, expected) { + t.Errorf("expected output to contain %q, but got:\n%s", expected, output) + } + } +} + +func TestBuildRunGet_JSON(t *testing.T) { + testBuildRun := &buildv1beta1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-buildrun", + Namespace: metav1.NamespaceDefault, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuildRun) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-buildrun"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Cmd().Flags().Set("output", "json"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + if !strings.Contains(output, `"name": "my-buildrun"`) { + t.Errorf("expected JSON output, but got:\n%s", output) + } +} + +func TestBuildRunGet_YAML(t *testing.T) { + testBuildRun := &buildv1beta1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-buildrun", + Namespace: metav1.NamespaceDefault, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuildRun) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-buildrun"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Cmd().Flags().Set("output", "yaml"); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + if !strings.Contains(output, "name: my-buildrun") { + t.Errorf("expected YAML output, but got:\n%s", output) + } +} + +func TestBuildRunGet_NotFound(t *testing.T) { + shpClientset := shpfake.NewSimpleClientset() + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := getCmd() + flags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, flags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"nonexistent-buildrun"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + expectedMsg := "BuildRun 'nonexistent-buildrun' not found in namespace 'default'." + if !strings.Contains(output, expectedMsg) { + t.Errorf("expected output to contain %q, but got:\n%s", expectedMsg, output) + } +} + +func ptr(s string) *string { + return &s +} From 2c1d2c8b8d2b04360288086402e944ad25670282 Mon Sep 17 00:00:00 2001 From: mohit-bhandari45 Date: Fri, 11 Sep 2026 12:29:20 +0530 Subject: [PATCH 2/5] feat(build): register update subcommand for shp build Signed-off-by: mohit-bhandari45 --- pkg/shp/cmd/build/build.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/shp/cmd/build/build.go b/pkg/shp/cmd/build/build.go index e9dfb4c71..d826d21cd 100644 --- a/pkg/shp/cmd/build/build.go +++ b/pkg/shp/cmd/build/build.go @@ -24,6 +24,7 @@ func Command(p *params.Params, ioStreams *genericclioptions.IOStreams) *cobra.Co // TODO: add support for `update` and `get` commands command.AddCommand( runner.NewRunner(p, ioStreams, getCmd()).Cmd(), + runner.NewRunner(p, ioStreams, updateCmd()).Cmd(), runner.NewRunner(p, ioStreams, createCmd()).Cmd(), runner.NewRunner(p, ioStreams, listCmd()).Cmd(), runner.NewRunner(p, ioStreams, deleteCmd()).Cmd(), From e13b6be8eacf16acc7e716fda74d06222d7f95e4 Mon Sep 17 00:00:00 2001 From: mohit-bhandari45 Date: Fri, 11 Sep 2026 12:30:00 +0530 Subject: [PATCH 3/5] feat(build): define UpdateCommand struct and updateCmd constructor Signed-off-by: mohit-bhandari45 --- pkg/shp/cmd/build/update.go | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 pkg/shp/cmd/build/update.go diff --git a/pkg/shp/cmd/build/update.go b/pkg/shp/cmd/build/update.go new file mode 100644 index 000000000..24487cb09 --- /dev/null +++ b/pkg/shp/cmd/build/update.go @@ -0,0 +1,60 @@ +package build // nolint:revive + +import ( + "fmt" + + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/spf13/cobra" + + "k8s.io/cli-runtime/pkg/genericclioptions" + + "github.com/shipwright-io/cli/pkg/shp/cmd/runner" + "github.com/shipwright-io/cli/pkg/shp/flags" + "github.com/shipwright-io/cli/pkg/shp/params" +) + +// UpdateCommand contains data provided by user to update an existing Build +type UpdateCommand struct { + cmd *cobra.Command + + name string + buildSpec *buildv1beta1.BuildSpec + dockerfile *string + builderImage *string +} + +func updateCmd() runner.SubCommand { + cmd := &cobra.Command{ + Use: "update [flags]", + Short: "Update an existing Build", + Args: cobra.ExactArgs(1), + } + + buildSpecFlags, dockerfileFlag, builderImageFlag := flags.BuildSpecFromFlags(cmd.Flags()) + + return &UpdateCommand{ + cmd: cmd, + buildSpec: buildSpecFlags, + dockerfile: dockerfileFlag, + builderImage: builderImageFlag, + } +} + +// Cmd returns cobra command object +func (c *UpdateCommand) Cmd() *cobra.Command { + return c.cmd +} + +// Complete fills object with user input data +func (c *UpdateCommand) Complete(_ *params.Params, _ *genericclioptions.IOStreams, args []string) error { + c.name = args[0] + return nil +} + +// Validate checks user input data +func (c *UpdateCommand) Validate() error { + if c.name == "" { + return fmt.Errorf("name must be provided") + } + return nil +} From b77ee4da8e553fae00fa8e67394b6a89752f2b79 Mon Sep 17 00:00:00 2001 From: mohit-bhandari45 Date: Fri, 11 Sep 2026 12:30:37 +0530 Subject: [PATCH 4/5] feat(build): implement Run method for shp build update command Signed-off-by: mohit-bhandari45 --- pkg/shp/cmd/build/update.go | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/pkg/shp/cmd/build/update.go b/pkg/shp/cmd/build/update.go index 24487cb09..deda29907 100644 --- a/pkg/shp/cmd/build/update.go +++ b/pkg/shp/cmd/build/update.go @@ -6,6 +6,8 @@ import ( buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" "github.com/spf13/cobra" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/cli-runtime/pkg/genericclioptions" "github.com/shipwright-io/cli/pkg/shp/cmd/runner" @@ -58,3 +60,101 @@ func (c *UpdateCommand) Validate() error { } return nil } + +// Run executes the update of an existing Build instance +func (c *UpdateCommand) Run(params *params.Params, ioStreams *genericclioptions.IOStreams) error { + clientset, err := params.ShipwrightClientSet() + if err != nil { + return err + } + + ns := params.Namespace() + build, err := clientset.ShipwrightV1beta1().Builds(ns).Get(c.cmd.Context(), c.name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + fmt.Fprintf(ioStreams.Out, "Build '%s' not found in namespace '%s'.\n", c.name, ns) + return nil + } + return err + } + + // Update Git Source URL if flag was explicitly provided + if c.cmd.Flags().Changed(flags.SourceGitURLFlag) && c.buildSpec.Source != nil && c.buildSpec.Source.Git != nil { + if build.Spec.Source == nil { + build.Spec.Source = &buildv1beta1.Source{} + } + if build.Spec.Source.Git == nil { + build.Spec.Source.Git = &buildv1beta1.Git{} + } + build.Spec.Source.Git.URL = c.buildSpec.Source.Git.URL + build.Spec.Source.Type = buildv1beta1.GitType + } + + // Update Git Revision if flag was explicitly provided + if c.cmd.Flags().Changed(flags.SourceGitRevisionFlag) && c.buildSpec.Source != nil && c.buildSpec.Source.Git != nil { + if build.Spec.Source == nil { + build.Spec.Source = &buildv1beta1.Source{} + } + if build.Spec.Source.Git == nil { + build.Spec.Source.Git = &buildv1beta1.Git{} + } + build.Spec.Source.Git.Revision = c.buildSpec.Source.Git.Revision + } + + // Update ContextDir if flag was explicitly provided + if c.cmd.Flags().Changed(flags.SourceContextDirFlag) && c.buildSpec.Source != nil { + if build.Spec.Source == nil { + build.Spec.Source = &buildv1beta1.Source{} + } + build.Spec.Source.ContextDir = c.buildSpec.Source.ContextDir + } + + // Update Strategy Name if flag was explicitly provided + if c.cmd.Flags().Changed(flags.StrategyNameFlag) { + build.Spec.Strategy.Name = c.buildSpec.Strategy.Name + } + + // Update Strategy Kind if flag was explicitly provided + if c.cmd.Flags().Changed(flags.StrategyKindFlag) { + build.Spec.Strategy.Kind = c.buildSpec.Strategy.Kind + } + + // Update Output Image if flag was explicitly provided + if c.cmd.Flags().Changed(flags.OutputImageFlag) { + build.Spec.Output.Image = c.buildSpec.Output.Image + } + + // Update dockerfile param if changed + if c.dockerfile != nil && *c.dockerfile != "" && c.cmd.Flags().Changed(flags.DockerfileFlag) { + updateOrAppendParam(&build.Spec.ParamValues, "dockerfile", *c.dockerfile) + } + + // Update builder-image param if changed + if c.builderImage != nil && *c.builderImage != "" && c.cmd.Flags().Changed(flags.BuilderImageFlag) { + updateOrAppendParam(&build.Spec.ParamValues, "builder-image", *c.builderImage) + } + + flags.SanitizeBuildSpec(&build.Spec) + + if _, err := clientset.ShipwrightV1beta1().Builds(ns).Update(c.cmd.Context(), build, metav1.UpdateOptions{}); err != nil { + return err + } + + fmt.Fprintf(ioStreams.Out, "Updated build %q\n", c.name) + return nil +} + +func updateOrAppendParam(paramValues *[]buildv1beta1.ParamValue, name string, val string) { + for i, p := range *paramValues { + if p.Name == name { + v := val + (*paramValues)[i].SingleValue = &buildv1beta1.SingleValue{Value: &v} + return + } + } + v := val + *paramValues = append(*paramValues, buildv1beta1.ParamValue{ + Name: name, + SingleValue: &buildv1beta1.SingleValue{Value: &v}, + }) +} From 8a3828351698859061fc230c20c6ce1751dc629a Mon Sep 17 00:00:00 2001 From: mohit-bhandari45 Date: Fri, 11 Sep 2026 12:31:05 +0530 Subject: [PATCH 5/5] test(build): add unit tests for shp build update command Signed-off-by: mohit-bhandari45 --- pkg/shp/cmd/build/update_test.go | 116 +++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 pkg/shp/cmd/build/update_test.go diff --git a/pkg/shp/cmd/build/update_test.go b/pkg/shp/cmd/build/update_test.go new file mode 100644 index 000000000..2d9e52f46 --- /dev/null +++ b/pkg/shp/cmd/build/update_test.go @@ -0,0 +1,116 @@ +package build // nolint:revive + +import ( + "strings" + "testing" + "time" + + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + shpfake "github.com/shipwright-io/build/pkg/client/clientset/versioned/fake" + "github.com/shipwright-io/cli/pkg/shp/flags" + "github.com/shipwright-io/cli/pkg/shp/params" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + kclientsetfake "k8s.io/client-go/kubernetes/fake" +) + +func TestBuildUpdate_Success(t *testing.T) { + oldRevision := "v1.0" + testBuild := &buildv1beta1.Build{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-build", + Namespace: metav1.NamespaceDefault, + }, + Spec: buildv1beta1.BuildSpec{ + Source: &buildv1beta1.Source{ + Git: &buildv1beta1.Git{ + URL: "https://github.com/shipwright-io/old-repo", + Revision: &oldRevision, + }, + }, + Output: buildv1beta1.Image{ + Image: "quay.io/myuser/old-app:v1.0", + }, + }, + } + + shpClientset := shpfake.NewSimpleClientset(testBuild) + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := updateCmd() + configFlags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, configFlags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"my-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + // Set updated flag values + if err := cmd.Cmd().Flags().Set(flags.SourceGitURLFlag, "https://github.com/shipwright-io/new-repo"); err != nil { + t.Fatalf("failed to set source-git-url flag: %v", err) + } + if err := cmd.Cmd().Flags().Set(flags.SourceGitRevisionFlag, "v2.0"); err != nil { + t.Fatalf("failed to set source-git-revision flag: %v", err) + } + if err := cmd.Cmd().Flags().Set(flags.OutputImageFlag, "quay.io/myuser/new-app:v2.0"); err != nil { + t.Fatalf("failed to set output-image flag: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + expectedMsg := `Updated build "my-build"` + if !strings.Contains(output, expectedMsg) { + t.Errorf("expected output to contain %q, but got:\n%s", expectedMsg, output) + } + + // Verify object fields were updated in client + updatedBuild, err := shpClientset.ShipwrightV1beta1().Builds(metav1.NamespaceDefault).Get(cmd.Cmd().Context(), "my-build", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get updated build: %v", err) + } + + if updatedBuild.Spec.Source.Git.URL != "https://github.com/shipwright-io/new-repo" { + t.Errorf("expected updated URL 'https://github.com/shipwright-io/new-repo', got %q", updatedBuild.Spec.Source.Git.URL) + } + + if *updatedBuild.Spec.Source.Git.Revision != "v2.0" { + t.Errorf("expected updated revision 'v2.0', got %q", *updatedBuild.Spec.Source.Git.Revision) + } + + if updatedBuild.Spec.Output.Image != "quay.io/myuser/new-app:v2.0" { + t.Errorf("expected updated output image 'quay.io/myuser/new-app:v2.0', got %q", updatedBuild.Spec.Output.Image) + } +} + +func TestBuildUpdate_NotFound(t *testing.T) { + shpClientset := shpfake.NewSimpleClientset() + k8sClientset := kclientsetfake.NewSimpleClientset() + + cmd := updateCmd() + configFlags := genericclioptions.NewConfigFlags(true) + timeout := 10 * time.Second + p := params.NewParamsForTest(k8sClientset, shpClientset, nil, configFlags, metav1.NamespaceDefault, &timeout, &timeout) + + ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() + + if err := cmd.Complete(p, &ioStreams, []string{"nonexistent-build"}); err != nil { + t.Fatalf("unexpected error in Complete: %v", err) + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("unexpected error in Run: %v", err) + } + + output := out.String() + expectedMsg := "Build 'nonexistent-build' not found in namespace 'default'." + if !strings.Contains(output, expectedMsg) { + t.Errorf("expected output to contain %q, but got:\n%s", expectedMsg, output) + } +}