From 081f94ac260fc2136d955fb343953cb0450411f9 Mon Sep 17 00:00:00 2001 From: karthik balasubramanian Date: Sun, 22 Mar 2026 01:54:18 +0530 Subject: [PATCH 1/4] add dynamic clientset to Params and tests Signed-off-by: karthik balasubramanian --- pkg/shp/cmd/build/run_test.go | 2 +- pkg/shp/cmd/buildrun/cancel_test.go | 2 +- pkg/shp/cmd/buildrun/logs_test.go | 4 +-- pkg/shp/params/params.go | 39 ++++++++++++++++++++++++++++- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/pkg/shp/cmd/build/run_test.go b/pkg/shp/cmd/build/run_test.go index 660e63430..8759ac91e 100644 --- a/pkg/shp/cmd/build/run_test.go +++ b/pkg/shp/cmd/build/run_test.go @@ -149,7 +149,7 @@ func TestStartBuildRunFollowLog(t *testing.T) { pm.Timeout = &tests[i].to } failureDuration := 1 * time.Millisecond - param := params.NewParamsForTest(kclientset, shpclientset, pm, metav1.NamespaceDefault, &failureDuration, &failureDuration) + param := params.NewParamsForTest(kclientset, shpclientset, nil, pm, metav1.NamespaceDefault, &failureDuration, &failureDuration) ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() diff --git a/pkg/shp/cmd/buildrun/cancel_test.go b/pkg/shp/cmd/buildrun/cancel_test.go index 44220ecaf..e767370b2 100644 --- a/pkg/shp/cmd/buildrun/cancel_test.go +++ b/pkg/shp/cmd/buildrun/cancel_test.go @@ -99,7 +99,7 @@ func TestCancelBuildRun(t *testing.T) { if _, err := cmd.Cmd().ExecuteC(); err != nil { t.Error(err.Error()) } - param := params.NewParamsForTest(nil, clientset, nil, metav1.NamespaceDefault, nil, nil) + param := params.NewParamsForTest(nil, clientset, nil, nil, metav1.NamespaceDefault, nil, nil) ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() err := cmd.Run(param, &ioStreams) diff --git a/pkg/shp/cmd/buildrun/logs_test.go b/pkg/shp/cmd/buildrun/logs_test.go index db8302e82..62d574afa 100644 --- a/pkg/shp/cmd/buildrun/logs_test.go +++ b/pkg/shp/cmd/buildrun/logs_test.go @@ -44,7 +44,7 @@ func TestStreamBuildLogs(t *testing.T) { clientset := fake.NewSimpleClientset(pod) ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() - param := params.NewParamsForTest(clientset, nil, nil, metav1.NamespaceDefault, nil, nil) + param := params.NewParamsForTest(clientset, nil, nil, nil, metav1.NamespaceDefault, nil, nil) err := cmd.Run(param, &ioStreams) if err != nil { t.Fatalf("%s", err.Error()) @@ -185,7 +185,7 @@ func TestStreamBuildRunFollowLogs(t *testing.T) { if len(test.to) > 0 { pm.Timeout = &tests[i].to } - param := params.NewParamsForTest(kclientset, shpclientset, pm, metav1.NamespaceDefault, nil, nil) + param := params.NewParamsForTest(kclientset, shpclientset, nil, pm, metav1.NamespaceDefault, nil, nil) ioStreams, _, out, _ := genericclioptions.NewTestIOStreams() diff --git a/pkg/shp/params/params.go b/pkg/shp/params/params.go index ae72ec5b4..58bd91875 100644 --- a/pkg/shp/params/params.go +++ b/pkg/shp/params/params.go @@ -11,6 +11,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" "k8s.io/kubectl/pkg/scheme" @@ -44,6 +45,7 @@ var hiddenKubeFlags = []string{ type Params struct { clientset kubernetes.Interface // kubernetes api-client, global instance buildClientset buildclientset.Interface // shipwright api-client, global instance + dynamicClient dynamic.Interface pw *reactor.PodWatcher // pod-watcher global instance follower *follower.Follower // follower global instance @@ -140,6 +142,31 @@ func (p *Params) ShipwrightClientSet() (buildclientset.Interface, error) { return p.buildClientset, nil } +// dynamic clientset to get tekton objects +func (p *Params) DynamicClientSet() (dynamic.Interface, error) { + if p.dynamicClient != nil { + return p.dynamicClient, nil + } + + clientConfig := p.configFlags.ToRawKubeConfigLoader() + config, err := clientConfig.ClientConfig() + if err != nil { + return nil, err + } + + p.namespace, _, err = clientConfig.Namespace() + if err != nil { + return nil, err + } + + p.dynamicClient, err = dynamic.NewForConfig(config) + if err != nil { + return nil, err + } + + return p.dynamicClient, nil +} + // Namespace returns kubernetes namespace with all the overrides // from command line and kubernetes config func (p *Params) Namespace() string { @@ -197,10 +224,11 @@ func (p *Params) NewFollower( } // WithClientset updates the shp CLI to use the provided Kubernetes and Build clientsets -func WithClientset(kubeClientset kubernetes.Interface, buildClientset buildclientset.Interface) Options { +func WithClientset(kubeClientset kubernetes.Interface, buildClientset buildclientset.Interface, dynamicClient dynamic.Interface) Options { return func(p *Params) { p.clientset = kubeClientset p.buildClientset = buildClientset + p.dynamicClient = dynamicClient } } @@ -211,6 +239,13 @@ func WithConfigFlags(configFlags *genericclioptions.ConfigFlags) Options { } } +// WithDynamicClient updates the shp CLI to use the provided dynamic client +func WithDynamicClient(dynamicClient dynamic.Interface) Options { + return func(p *Params) { + p.dynamicClient = dynamicClient + } +} + // WithNamespace updates the shp CLI to use the provided namespace func WithNamespace(namespace string) Options { return func(p *Params) { @@ -235,6 +270,7 @@ func NewParams(options ...Options) *Params { // NewParamsForTest creates an instance of Params for testing purpose func NewParamsForTest(clientset kubernetes.Interface, shpClientset buildclientset.Interface, + dynamicClient dynamic.Interface, configFlags *genericclioptions.ConfigFlags, namespace string, failPollInterval *time.Duration, @@ -244,6 +280,7 @@ func NewParamsForTest(clientset kubernetes.Interface, return &Params{ clientset: clientset, buildClientset: shpClientset, + dynamicClient: dynamicClient, configFlags: configFlags, namespace: namespace, failPollInterval: failPollInterval, From de5197583cb04a14a5736eeb9c823f1018db1d52 Mon Sep 17 00:00:00 2001 From: karthik balasubramanian Date: Sun, 22 Mar 2026 20:25:50 +0530 Subject: [PATCH 2/4] feat: gather command to fetch failed buildrun diagnostics Signed-off-by: karthik balasubramanian --- pkg/shp/cmd/buildrun/buildrun.go | 1 + pkg/shp/cmd/buildrun/gather.go | 470 +++++++++++++++++++++++++++++++ pkg/shp/params/params.go | 2 +- 3 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 pkg/shp/cmd/buildrun/gather.go diff --git a/pkg/shp/cmd/buildrun/buildrun.go b/pkg/shp/cmd/buildrun/buildrun.go index 3e80d89fd..01983214f 100644 --- a/pkg/shp/cmd/buildrun/buildrun.go +++ b/pkg/shp/cmd/buildrun/buildrun.go @@ -27,6 +27,7 @@ func Command(p *params.Params, ioStreams *genericclioptions.IOStreams) *cobra.Co runner.NewRunner(p, ioStreams, createCmd()).Cmd(), runner.NewRunner(p, ioStreams, cancelCmd()).Cmd(), runner.NewRunner(p, ioStreams, deleteCmd()).Cmd(), + runner.NewRunner(p, ioStreams, gatherCmd()).Cmd(), ) return command } diff --git a/pkg/shp/cmd/buildrun/gather.go b/pkg/shp/cmd/buildrun/gather.go new file mode 100644 index 000000000..1298ad8c1 --- /dev/null +++ b/pkg/shp/cmd/buildrun/gather.go @@ -0,0 +1,470 @@ +package buildrun + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + buildv1beta1 "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/yaml" + + "github.com/shipwright-io/cli/pkg/shp/cmd/runner" + "github.com/shipwright-io/cli/pkg/shp/params" + shputil "github.com/shipwright-io/cli/pkg/shp/util" +) + +// GatherCommand struct stores user input for gather subcommand. +type GatherCommand struct { + cmd *cobra.Command + + name string + outputDir string + archive bool +} + +var taskRunGVR = schema.GroupVersionResource{ + Group: "tekton.dev", + Version: "v1", + Resource: "taskruns", +} + +var pipelineRunGVR = schema.GroupVersionResource{ + Group: "tekton.dev", + Version: "v1", + Resource: "pipelineruns", +} + +const gatherLongDesc = ` +Gather collects the BuildRun object, its executor object, related execution +resources, and available container logs into a single directory. + +For BuildRuns executed by a TaskRun, the command writes: + + buildrun.yaml + taskrun.yaml + pod.yaml + logs/*.log + +For BuildRuns executed by a PipelineRun, the command writes: + + buildrun.yaml + pipelinerun.yaml + taskruns/*.yaml + pods/*.yaml + logs//*.log + +Use --archive to package the gathered files as a .tar.gz archive. +` + +func gatherCmd() runner.SubCommand { + cmd := &cobra.Command{ + Use: "gather ", + Short: "Gather BuildRun diagnostics into a single directory or archive.", + Long: gatherLongDesc, + Args: cobra.ExactArgs(1), + } + + gatherCommand := &GatherCommand{ + cmd: cmd, + outputDir: ".", + } + // archive is by default set to false. + cmd.Flags().BoolVarP(&gatherCommand.archive, "archive", "z", gatherCommand.archive, "package gathered diagnostics as a .tar.gz archive") + cmd.Flags().StringVarP(&gatherCommand.outputDir, "output", "o", gatherCommand.outputDir, "directory to write gathered files") + + return gatherCommand +} + +// Cmd returns cobra command object +func (c *GatherCommand) Cmd() *cobra.Command { + return c.cmd +} + +// Complete fills in data provided by user +func (c *GatherCommand) Complete(_ *params.Params, _ *genericclioptions.IOStreams, args []string) error { + c.name = args[0] + return nil +} + +// Validate validates data input by user +func (c *GatherCommand) Validate() error { + if c.name == "" { + return fmt.Errorf("buildrun name is required") + } + if c.outputDir == "" { + return fmt.Errorf("output directory cannot be empty") + } + return nil +} + +// Run executes gather sub-command logic +func (c *GatherCommand) Run(p *params.Params, ioStreams *genericclioptions.IOStreams) error { + ctx := c.cmd.Context() + namespace := p.Namespace() + + shpClient, err := p.ShipwrightClientSet() + if err != nil { + return err + } + + kubeClient, err := p.ClientSet() + if err != nil { + return err + } + + buildRun, err := shpClient.ShipwrightV1beta1().BuildRuns(namespace).Get(ctx, c.name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get BuildRun %q: %w", c.name, err) + } + + targetDir := filepath.Join(c.outputDir, fmt.Sprintf("buildrun-%s-gather", c.name)) + if err := createOutputDir(targetDir); err != nil { + return err + } + + logsDir := filepath.Join(targetDir, "logs") + if err := os.MkdirAll(logsDir, 0o750); err != nil { + return fmt.Errorf("error in creating logs directory: %w", err) + } + + if err := writeYAMLFile(filepath.Join(targetDir, "buildrun.yaml"), buildRun); err != nil { + return err + } + + executorKind, executorName := executorForBuildRun(buildRun) + + switch executorKind { + case "": + fmt.Fprintf(ioStreams.ErrOut, "warning: BuildRun %q does not reference an executor yet\n", c.name) + case "TaskRun": + err := c.gatherTaskRunExecutor(ctx, p, ioStreams, namespace, targetDir, executorName, kubeClient) + if err != nil { + return err + } + case "PipelineRun": + err := c.gatherPipelineRunExecutor(ctx, p, ioStreams, namespace, targetDir, executorName, kubeClient) + if err != nil { + return err + } + default: + return fmt.Errorf("BuildRun %q uses unsupported executor kind %q", c.name, executorKind) + } + + finalPath := targetDir + if c.archive { + archivePath := finalPath + ".tar.gz" + if err := createTargz(targetDir, archivePath); err != nil { + return err + } + + if err := os.RemoveAll(targetDir); err != nil { + return err + } + + finalPath = archivePath + } + + fmt.Fprintf(ioStreams.Out, "BuildRun diagnostics written to %q\n", finalPath) + return nil +} + +func (c *GatherCommand) gatherTaskRunExecutor( + ctx context.Context, + p *params.Params, + ioStreams *genericclioptions.IOStreams, + namespace string, + targetDir string, + executorName string, + kubeClient kubernetes.Interface, +) error { + + dynamicClient, err := p.DynamicClientSet() + if err != nil { + return err + } + + taskRunObj, err := dynamicClient.Resource(taskRunGVR).Namespace(namespace).Get(ctx, executorName, metav1.GetOptions{}) + var podName string + logsDir := filepath.Join(targetDir, "logs") + + switch { + case err == nil: + if err = writeYAMLFile(filepath.Join(targetDir, "taskrun.yaml"), taskRunObj.Object); err != nil { + return err + } + if name, found, nestedErr := unstructured.NestedString(taskRunObj.Object, "status", "podName"); nestedErr == nil && found { + podName = name + } + case k8serrors.IsNotFound(err): + fmt.Fprintf(ioStreams.ErrOut, "warning: TaskRun %q referenced by BuildRun %q was not found\n", executorName, c.name) + return nil + default: + return err + } + + pod, err := resolvePodForTaskRun(ctx, kubeClient, namespace, taskRunObj.GetName(), podName) + if err != nil { + return err + } + + if pod == nil { + fmt.Fprintf(ioStreams.ErrOut, "warning: no Pod found for BuildRun %q\n", c.name) + } else { + if err := writeYAMLFile(filepath.Join(targetDir, "pod.yaml"), pod); err != nil { + return err + } + + for _, container := range append(pod.Spec.InitContainers, pod.Spec.Containers...) { + logText, err := shputil.GetPodLogs(ctx, kubeClient, *pod, container.Name) + if err != nil { + fmt.Fprintf(ioStreams.ErrOut, "warning: could not fetch logs for Pod %q container %q: %s\n", + pod.Name, + container.Name, + err.Error(), + ) + continue + } + + logPath := filepath.Join(logsDir, fmt.Sprintf("%s.log", container.Name)) + if err := os.WriteFile(logPath, []byte(logText), 0o600); err != nil { + return fmt.Errorf("failed to write logs %w", err) + } + } + } + + return nil +} + +func (c *GatherCommand) gatherPipelineRunExecutor( + ctx context.Context, + p *params.Params, + ioStreams *genericclioptions.IOStreams, + namespace string, + targetDir string, + executorName string, + kubeClient kubernetes.Interface, +) error { + dynamicClient, err := p.DynamicClientSet() + if err != nil { + return err + } + + pipelineRunObj, err := dynamicClient.Resource(pipelineRunGVR).Namespace(namespace).Get(ctx, executorName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get PipelineRun %q: %w", executorName, err) + } + + if err := writeYAMLFile(filepath.Join(targetDir, "pipelinerun.yaml"), pipelineRunObj.Object); err != nil { + return err + } + + taskrunList, err := dynamicClient.Resource(taskRunGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("tekton.dev/pipelineRun=%s", executorName), + }) + if err != nil { + return fmt.Errorf("failed to list TaskRuns for PipelineRun %q: %w", executorName, err) + } + if len(taskrunList.Items) == 0 { + fmt.Fprintf(ioStreams.ErrOut, "warning: PipelineRun %q did not produce any TaskRuns yet\n", executorName) + return nil + } + + taskRunsDir := filepath.Join(targetDir, "taskruns") + podsDir := filepath.Join(targetDir, "pods") + logsDir := filepath.Join(targetDir, "logs") + + for _, dir := range []string{taskRunsDir, podsDir, logsDir} { + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + } + + for _, taskRun := range taskrunList.Items { + taskRunName := taskRun.GetName() + + taskRunPath := filepath.Join(taskRunsDir, fmt.Sprintf("%s.yaml", taskRunName)) + if err := writeYAMLFile(taskRunPath, taskRun.Object); err != nil { + return err + } + + podName, found, nestedErr := unstructured.NestedString(taskRun.Object, "status", "podName") + if nestedErr != nil { + return fmt.Errorf("failed to inspect status.podName for TaskRun %q: %w", taskRunName, nestedErr) + } + if !found { + podName = "" + } + + pod, err := resolvePodForTaskRun(ctx, kubeClient, namespace, taskRunName, podName) + if err != nil { + return err + } + + if pod == nil { + fmt.Fprintf(ioStreams.ErrOut, "warning: no Pod found for TaskRun %q in PipelineRun %q\n", taskRunName, executorName) + continue + } + + podPath := filepath.Join(podsDir, fmt.Sprintf("%s.yaml", pod.Name)) + if err := writeYAMLFile(podPath, pod); err != nil { + return err + } + + taskRunLogsDir := filepath.Join(logsDir, taskRunName) + if err := os.MkdirAll(taskRunLogsDir, 0o750); err != nil { + return err + } + + for _, container := range append(pod.Spec.InitContainers, pod.Spec.Containers...) { + logText, err := shputil.GetPodLogs(ctx, kubeClient, *pod, container.Name) + if err != nil { + fmt.Fprintf(ioStreams.ErrOut, "warning: could not fetch logs for Pod %q container %q: %s\n", + pod.Name, + container.Name, + err.Error(), + ) + continue + } + + logPath := filepath.Join(taskRunLogsDir, fmt.Sprintf("%s.log", container.Name)) + if err := os.WriteFile(logPath, []byte(logText), 0o600); err != nil { + return fmt.Errorf("failed to write logs %w", err) + } + } + } + return nil +} + +func resolvePodForTaskRun(ctx context.Context, client kubernetes.Interface, namespace string, taskRunName string, podName string) (*corev1.Pod, error) { + if podName != "" { + pod, err := client.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) + switch { + case err == nil: + return pod, nil + case k8serrors.IsNotFound(err): + default: + return nil, err + } + } + + podList, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("tekton.dev/taskRun=%s", taskRunName), + }) + if err != nil { + return nil, err + } + if len(podList.Items) == 0 { + return nil, nil + } + + return &podList.Items[0], nil +} + +func executorForBuildRun(br *buildv1beta1.BuildRun) (kind string, name string) { + if br == nil { + return "", "" + } + + if br.Status.Executor != nil && br.Status.Executor.Name != "" { + return br.Status.Executor.Kind, br.Status.Executor.Name + } + + return "", "" +} + +func createOutputDir(path string) error { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("filepath %s already exists", path) + } else if !os.IsNotExist(err) { + return err + } + + return os.MkdirAll(path, 0o750) +} + +func writeYAMLFile(path string, object any) error { + data, err := yaml.Marshal(object) + if err != nil { + return fmt.Errorf("failed to marshal object to yaml: %w", err) + } + return os.WriteFile(path, data, 0o600) +} + +func createTargz(sourceDir, archivePath string) error { + // #nosec G304 -- create the file in path provided by the user + file, err := os.Create(archivePath) + if err != nil { + return err + } + defer file.Close() + + gzipWriter := gzip.NewWriter(file) + defer gzipWriter.Close() + + tarWriter := tar.NewWriter(gzipWriter) + defer tarWriter.Close() + + rootFS := os.DirFS(sourceDir) + + // use sourceDir as the root to prevent TOCTOU problems + return fs.WalkDir(rootFS, ".", func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + // Tar handles structure by filepath so skip directories + if d.IsDir() { + return nil + } + + relpath := path + + info, err := d.Info() + if err != nil { + return err + } + + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + + // Avoid cross platform bugs (windows uses \ slash) + header.Name = filepath.ToSlash(relpath) + + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + // Open file via rootFS (prevents path escape) + sourceFile, err := rootFS.Open(path) + if err != nil { + return err + } + + _, err = io.Copy(tarWriter, sourceFile) + if err != nil { + _ = sourceFile.Close() + return err + } + + if err := sourceFile.Close(); err != nil { + return err + } + + return nil + }) +} diff --git a/pkg/shp/params/params.go b/pkg/shp/params/params.go index 58bd91875..106bd5c6d 100644 --- a/pkg/shp/params/params.go +++ b/pkg/shp/params/params.go @@ -142,7 +142,7 @@ func (p *Params) ShipwrightClientSet() (buildclientset.Interface, error) { return p.buildClientset, nil } -// dynamic clientset to get tekton objects +// DynamicClientSet to get tekton objects func (p *Params) DynamicClientSet() (dynamic.Interface, error) { if p.dynamicClient != nil { return p.dynamicClient, nil From dbc1236c2ef191d5af2ef75f133e30608f160c03 Mon Sep 17 00:00:00 2001 From: karthik balasubramanian Date: Sun, 22 Mar 2026 20:47:32 +0530 Subject: [PATCH 3/4] doc: add shp buildrun gather command documentation Signed-off-by: karthik balasubramanian --- docs/shp_buildrun.md | 1 + docs/shp_buildrun_gather.md | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 docs/shp_buildrun_gather.md diff --git a/docs/shp_buildrun.md b/docs/shp_buildrun.md index 8f657a063..63442fa6f 100644 --- a/docs/shp_buildrun.md +++ b/docs/shp_buildrun.md @@ -26,6 +26,7 @@ shp buildrun [flags] * [shp buildrun cancel](shp_buildrun_cancel.md) - Cancel BuildRun * [shp buildrun create](shp_buildrun_create.md) - Creates a BuildRun instance. * [shp buildrun delete](shp_buildrun_delete.md) - Delete BuildRun +* [shp buildrun gather](shp_buildrun_gather.md) - Gather BuildRun diagnostics into a single directory or archive. * [shp buildrun list](shp_buildrun_list.md) - List Builds * [shp buildrun logs](shp_buildrun_logs.md) - See BuildRun log output diff --git a/docs/shp_buildrun_gather.md b/docs/shp_buildrun_gather.md new file mode 100644 index 000000000..babbc6d99 --- /dev/null +++ b/docs/shp_buildrun_gather.md @@ -0,0 +1,52 @@ +## shp buildrun gather + +Gather BuildRun diagnostics into a single directory or archive. + +### Synopsis + + +Gather collects the BuildRun object, its executor object, related execution +resources, and available container logs into a single directory. + +For BuildRuns executed by a TaskRun, the command writes: + + buildrun.yaml + taskrun.yaml + pod.yaml + logs/*.log + +For BuildRuns executed by a PipelineRun, the command writes: + + buildrun.yaml + pipelinerun.yaml + taskruns/*.yaml + pods/*.yaml + logs//*.log + +Use --archive to package the gathered files as a .tar.gz archive. + + +``` +shp buildrun gather [flags] +``` + +### Options + +``` + -z, --archive package gathered diagnostics as a .tar.gz archive + -h, --help help for gather + -o, --output string directory to write gathered files (default ".") +``` + +### Options inherited from parent commands + +``` + --kubeconfig string Path to the kubeconfig file to use for CLI requests. + -n, --namespace string If present, the namespace scope for this CLI request + --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") +``` + +### SEE ALSO + +* [shp buildrun](shp_buildrun.md) - Manage BuildRuns + From f89a76b937ab6352ff4bf13ec4c53b09d525b298 Mon Sep 17 00:00:00 2001 From: karthik balasubramanian Date: Tue, 31 Mar 2026 03:57:32 +0530 Subject: [PATCH 4/4] test: add tests for shp buildrun gather subcommand Signed-off-by: karthik balasubramanian --- pkg/shp/cmd/buildrun/gather_test.go | 220 ++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 pkg/shp/cmd/buildrun/gather_test.go diff --git a/pkg/shp/cmd/buildrun/gather_test.go b/pkg/shp/cmd/buildrun/gather_test.go new file mode 100644 index 000000000..122801c67 --- /dev/null +++ b/pkg/shp/cmd/buildrun/gather_test.go @@ -0,0 +1,220 @@ +package buildrun + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + 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" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/cli-runtime/pkg/genericclioptions" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/kubernetes/scheme" +) + +func TestGatherTaskRun(t *testing.T) { + name := "test-br-taskrun" + namespace := "default" + executorName := "test-tr" + podName := "test-pod" + + buildRun := &buildv1beta1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Status: buildv1beta1.BuildRunStatus{ + Executor: &buildv1beta1.BuildExecutor{Kind: "TaskRun", Name: executorName}, + }, + } + + taskRun := &unstructured.Unstructured{ + Object: map[string]interface{} { + "apiVersion": "tekton.dev/v1", + "kind": "TaskRun", + "metadata": map[string]interface{}{"name": executorName, "namespace": namespace}, + "status": map[string]interface{}{"podName": podName}, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: namespace, + Labels: map[string]string{"tekton.dev/taskRun": executorName}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "step-build"}}}, + } + + shpClient := shpfake.NewSimpleClientset(buildRun) + kubeClient := fake.NewSimpleClientset(pod) + dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme, taskRun) + + tmpDir, _ := os.MkdirTemp("", "gather-tr-*") + defer os.RemoveAll(tmpDir) + + p := params.NewParamsForTest(kubeClient, shpClient, dynamicClient, nil, namespace, nil, nil) + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + c := &cobra.Command{} + c.SetContext(context.Background()) + cmd := &GatherCommand{cmd: c, name: name, outputDir: tmpDir} + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("Gather.Run failed: %v", err) + } + + expectedDir := filepath.Join(tmpDir, fmt.Sprintf("buildrun-%s-gather", name)) + checkFiles(t, expectedDir, []string{"buildrun.yaml", "taskrun.yaml", "pod.yaml", "logs/step-build.log"}) +} + +func TestGatherPipelineRun(t *testing.T) { + name := "test-br-pipelinerun" + namespace := "default" + pipelineRunName := "test-pr" + taskRunName := "test-pr-tr" + podName := "test-pr-pod" + + buildRun := &buildv1beta1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Status: buildv1beta1.BuildRunStatus{ + Executor: &buildv1beta1.BuildExecutor{Kind: "PipelineRun", Name: pipelineRunName}, + }, + } + + pipelineRun := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "tekton.dev/v1", + "kind": "PipelineRun", + "metadata": map[string]interface{}{"name": pipelineRunName, "namespace": namespace}, + }, + } + + taskRun := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "tekton.dev/v1", + "kind": "TaskRun", + "metadata": map[string]interface{}{ + "name": taskRunName, + "namespace": namespace, + "labels": map[string]interface{}{"tekton.dev/pipelineRun": pipelineRunName}, + }, + "status": map[string]interface{}{"podName": podName}, + }, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: namespace, + Labels: map[string]string{"tekton.dev/taskRun": taskRunName}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "step-build"}}}, + } + + shpClient := shpfake.NewSimpleClientset(buildRun) + kubeClient := fake.NewSimpleClientset(pod) + + // Seed dynamic client with both PipelineRun and TaskRun + dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme, pipelineRun, taskRun) + + tmpDir, _ := os.MkdirTemp("", "gather-pr-*") + defer os.RemoveAll(tmpDir) + + p := params.NewParamsForTest(kubeClient, shpClient, dynamicClient, nil, namespace, nil, nil) + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + + c := &cobra.Command{} + c.SetContext(context.Background()) + cmd := &GatherCommand{cmd: c, name: name, outputDir: tmpDir} + + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("Gather.Run failed: %v", err) + } + + expectedDir := filepath.Join(tmpDir, fmt.Sprintf("buildrun-%s-gather", name)) + + + // PipelineRun gather creates a slightly different directory structure + checkFiles(t, expectedDir, []string{ + "buildrun.yaml", + "pipelinerun.yaml", + filepath.Join("taskruns", taskRunName +".yaml"), + filepath.Join("pods", podName+".yaml"), + filepath.Join("logs", taskRunName, "step-build.log"), + }) +} + +func TestGatherArchive(t *testing.T) { + name := "test-br-archive" + namespace := "default" + executorName := "test-br" + + buildRun := &buildv1beta1.BuildRun{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Status: buildv1beta1.BuildRunStatus{ + Executor: &buildv1beta1.BuildExecutor{Kind: "TaskRun", Name: executorName}, + }, + } + + taskRun := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "tekton.dev/v1", + "kind": "TaskRun", + "metadata": map[string]interface{}{"name": executorName, "namespace": namespace}, + "status": map[string]interface{}{"podName": "some-pod"}, + }, + } + + shpClient := shpfake.NewSimpleClientset(buildRun) + kubeClient := fake.NewSimpleClientset() // Pod not strictly needed for archive logic test + dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme, taskRun) + + tmpDir, _ := os.MkdirTemp("", "gather-archive-*") + defer os.RemoveAll(tmpDir) + + p := params.NewParamsForTest(kubeClient, shpClient, dynamicClient, nil, namespace, nil, nil) + ioStreams, _, _, _ := genericclioptions.NewTestIOStreams() + + c := &cobra.Command{} + c.SetContext(context.Background()) + + // Enable the archive flag + cmd := &GatherCommand{ + cmd: c, + name: name, + outputDir: tmpDir, + archive: true, + } + + if err := cmd.Run(p, &ioStreams); err != nil { + t.Fatalf("Gather.Run failed: %v", err) + } + + expectedPrefix := filepath.Join(tmpDir, fmt.Sprintf("buildrun-%s-gather", name)) + archivePath := expectedPrefix + ".tar.gz" + + // Verify the archive file exists + if _, err := os.Stat(archivePath); os.IsNotExist(err) { + t.Errorf("expected archive file %q was not created", archivePath) + } + + // Verify the temporary directory was cleaned up (deleted) + if _, err := os.Stat(expectedPrefix); err == nil { + t.Errorf("expected directory %q to be deleted after archiving, but it still exists", expectedPrefix) + } +} + +func checkFiles(t *testing.T, baseDir string, files []string) { + for _, f := range files { + path := filepath.Join(baseDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected file %q not found in gathered directory", path) + } + } +} \ No newline at end of file