diff --git a/pkg/shp/cmd/build/build.go b/pkg/shp/cmd/build/build.go index 80c1f6243..f538ab8d8 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, updateCmd()).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/update.go b/pkg/shp/cmd/build/update.go new file mode 100644 index 000000000..deda29907 --- /dev/null +++ b/pkg/shp/cmd/build/update.go @@ -0,0 +1,160 @@ +package build // nolint:revive + +import ( + "fmt" + + 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" + "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 +} + +// 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}, + }) +} 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) + } +}