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 + 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/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/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/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/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..106bd5c6d 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 } +// DynamicClientSet 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,