From da400b46120ebb87f8ca6e6532271579a8c9de06 Mon Sep 17 00:00:00 2001 From: shverma Date: Fri, 26 Sep 2025 00:07:13 +0530 Subject: [PATCH] Add --resolvertype flag to rerun a resolver based pipelinerun - Add --resolvertype flag supporting hub, git, http, cluster, bundle and remote resolver type - Implement special remote resolver that finds latest PipelineRun with any resolver type and rerun it - If --resolvertype flag value is except remote, filters pipelinerun by that resolver type - Integrate --resolvertype with existing --last flag functionality - With --resolvertype and --last flags pipeline name is optional - With --last flag pipeline name is optional - Add validation: resolvertype requires pipeline name except for remote type - Support all flag combinations: --resolvertype only, --last only, both together - Include the unit tests closes: #2422 Signed-off-by: Shiv Verma --- docs/cmd/tkn_pipeline_start.md | 1 + docs/man/man1/tkn-pipeline-start.1 | 4 + pkg/cmd/pipeline/start.go | 458 ++++++++++++++++++++++++++- pkg/cmd/pipeline/start_test.go | 476 +++++++++++++++++++++++++++++ 4 files changed, 938 insertions(+), 1 deletion(-) diff --git a/docs/cmd/tkn_pipeline_start.md b/docs/cmd/tkn_pipeline_start.md index 7c3acf1739..1e8b946060 100644 --- a/docs/cmd/tkn_pipeline_start.md +++ b/docs/cmd/tkn_pipeline_start.md @@ -74,6 +74,7 @@ my-csi-template and my-volume-claim-template) --pipeline-timeout string timeout for PipelineRun --pod-template string local or remote file containing a PodTemplate definition --prefix-name string specify a prefix for the PipelineRun name (must be lowercase alphanumeric characters) + --resolvertype string resolver type for remote pipelines (hub, git, http, cluster, bundles, remote) -s, --serviceaccount string pass the serviceaccount name --showlog show logs right after starting the Pipeline --skip-optional-workspace skips the prompt for optional workspaces diff --git a/docs/man/man1/tkn-pipeline-start.1 b/docs/man/man1/tkn-pipeline-start.1 index eac1634b6a..1a303a6df5 100644 --- a/docs/man/man1/tkn-pipeline-start.1 +++ b/docs/man/man1/tkn-pipeline-start.1 @@ -75,6 +75,10 @@ Parameters, at least those that have no default value \fB\-\-prefix\-name\fP="" specify a prefix for the PipelineRun name (must be lowercase alphanumeric characters) +.PP +\fB\-\-resolvertype\fP="" + resolver type for remote pipelines (hub, git, http, cluster, bundles, remote) + .PP \fB\-s\fP, \fB\-\-serviceaccount\fP="" pass the serviceaccount name diff --git a/pkg/cmd/pipeline/start.go b/pkg/cmd/pipeline/start.go index 53a49326ff..2c3c2c5f25 100644 --- a/pkg/cmd/pipeline/start.go +++ b/pkg/cmd/pipeline/start.go @@ -81,6 +81,7 @@ type startOptions struct { TektonOptions flags.TektonOptions PodTemplate string SkipOptionalWorkspace bool + ResolverType string } func startCommand(p cli.Params) *cobra.Command { @@ -106,6 +107,18 @@ func startCommand(p cli.Params) *cobra.Command { tkn pipeline start foo -s ServiceAccountName -n bar + Re-run the last PipelineRun for a specific pipeline + + tkn pipeline start foo --last -n bar + + Re-run the last PipelineRun that used any remote resolver + + tkn pipeline start foo --last --resolvertype=remote -n bar + + Re-run the last PipelineRun that used git resolver + + tkn pipeline start --last --resolvertype=git -n bar + For params value, if you want to provide multiple values, provide them comma separated like cat,foo,bar @@ -134,7 +147,7 @@ For passing the workspaces via flags: SilenceUsage: true, ValidArgsFunction: formatted.ParentCompletion, - Args: func(cmd *cobra.Command, _ []string) error { + Args: func(cmd *cobra.Command, args []string) error { if err := flags.InitParams(p, cmd); err != nil { return err } @@ -147,6 +160,34 @@ For passing the workspaces via flags: if opt.UseParamDefaults && (opt.Last || opt.UsePipelineRun != "") { return errors.New("cannot use --last or --use-pipelinerun options with --use-param-defaults option") } + + // Validate resolvertype values + if opt.ResolverType != "" { + validResolvers := []string{"hub", "git", "http", "cluster", "bundles", "remote"} + isValid := false + for _, valid := range validResolvers { + if opt.ResolverType == valid { + isValid = true + break + } + } + if !isValid { + return fmt.Errorf("invalid resolvertype '%s'. Valid values are: %s", opt.ResolverType, strings.Join(validResolvers, ", ")) + } + } + + // Validate flag combinations according to requirements + if opt.ResolverType != "" && !opt.Last { + // Case: --resolvertype only + // Special case: remote resolver doesn't require pipeline name (it finds latest with any resolver) + if opt.ResolverType != "remote" && len(args) == 0 && opt.Filename == "" { + return errors.New("pipeline name is required when using --resolvertype flag") + } + } + + // Case: --resolvertype and --last (pipeline name is optional) + // Case: --last only (pipeline name is optional) - already handled by existing logic + format := strings.ToLower(opt.Output) if format != "" && format != "json" && format != "yaml" && format != "name" { return fmt.Errorf("output format specified is %s but must be yaml or json", opt.Output) @@ -163,6 +204,11 @@ For passing the workspaces via flags: Err: cmd.OutOrStderr(), } + // Handle different scenarios based on flags + if opt.ResolverType != "" { + return opt.runWithResolver(args) + } + pipeline, err := NameArg(args, p, opt.Filename) if err != nil { return err @@ -211,6 +257,14 @@ For passing the workspaces via flags: return formatted.BaseCompletion("serviceaccount", args) }, ) + + c.Flags().StringVar(&opt.ResolverType, "resolvertype", "", "resolver type for remote pipelines (hub, git, http, cluster, bundles, remote)") + _ = c.RegisterFlagCompletionFunc("resolvertype", + func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"hub", "git", "http", "cluster", "bundles", "remote"}, cobra.ShellCompDirectiveNoFileComp + }, + ) + return c } @@ -222,6 +276,408 @@ func (opt *startOptions) run(pipeline *v1beta1.Pipeline) error { return opt.startPipeline(pipeline) } +func (opt *startOptions) runWithResolver(args []string) error { + cs, err := opt.cliparams.Clients() + if err != nil { + return err + } + + var pipelineName string + if len(args) > 0 { + pipelineName = args[0] + } + + // Special case: if resolvertype is "remote", find and rerun the latest PipelineRun with any resolver + if opt.ResolverType == "remote" { + return opt.runWithRemoteResolver(cs, pipelineName) + } + + if opt.Last { + // Case: --resolvertype and --last + return opt.runWithResolverAndLast(cs, pipelineName) + } + // Case: --resolvertype only + if pipelineName == "" { + return errors.New("pipeline name is required when using --resolvertype flag") + } + return opt.runWithResolverOnly(cs, pipelineName) +} + +func (opt *startOptions) runWithResolverOnly(_ *cli.Clients, pipelineName string) error { + // Create PipelineRun with resolver reference + objMeta := metav1.ObjectMeta{ + Namespace: opt.cliparams.Namespace(), + GenerateName: pipelineName + "-run-", + } + + pr := &v1beta1.PipelineRun{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "PipelineRun", + }, + ObjectMeta: objMeta, + Spec: v1beta1.PipelineRunSpec{ + PipelineRef: &v1beta1.PipelineRef{ + ResolverRef: v1beta1.ResolverRef{ + Resolver: v1beta1.ResolverName(opt.ResolverType), + Params: []v1beta1.Param{ + { + Name: "name", + Value: v1beta1.ParamValue{StringVal: pipelineName}, + }, + }, + }, + }, + }, + } + + return opt.createAndRunPipelineRun(pr) +} + +func (opt *startOptions) runWithResolverAndLast(cs *cli.Clients, pipelineName string) error { + var lastPipelineRun *v1beta1.PipelineRun + var err error + + if pipelineName != "" { + // Get last run for specific pipeline + name, err := pipelinepkg.LastRunName(cs, pipelineName, opt.cliparams.Namespace()) + if err != nil { + return err + } + lastPipelineRun, err = getPipelineRunV1beta1(pipelineRunGroupResource, cs, name, opt.cliparams.Namespace()) + if err != nil { + return err + } + } else { + // Get last run from any pipeline with resolver + lastPipelineRun, err = opt.getLastPipelineRunWithResolver(cs) + if err != nil { + return err + } + } + + // Check if the last run used a resolver + if lastPipelineRun.Spec.PipelineRef == nil || lastPipelineRun.Spec.PipelineRef.ResolverRef.Resolver == "" { + return errors.New("last PipelineRun did not use a resolver") + } + + // Create new PipelineRun based on the last one + objMeta := metav1.ObjectMeta{ + Namespace: opt.cliparams.Namespace(), + } + + switch { + case len(lastPipelineRun.ObjectMeta.GenerateName) > 0 && opt.PrefixName == "": + objMeta.GenerateName = lastPipelineRun.ObjectMeta.GenerateName + case opt.PrefixName == "": + objMeta.GenerateName = lastPipelineRun.ObjectMeta.Name + "-" + default: + objMeta.GenerateName = opt.PrefixName + "-" + } + + pr := &v1beta1.PipelineRun{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "PipelineRun", + }, + ObjectMeta: objMeta, + Spec: lastPipelineRun.Spec, + } + + // Reapply blank status in case PipelineRun used was cancelled + pr.Spec.Status = "" + + return opt.createAndRunPipelineRun(pr) +} + +func (opt *startOptions) getLastPipelineRunWithResolver(cs *cli.Clients) (*v1beta1.PipelineRun, error) { + options := metav1.ListOptions{} + + var runs *v1.PipelineRunList + err := actions.ListV1(pipelineRunGroupResource, cs, options, opt.cliparams.Namespace(), &runs) + if err != nil { + return nil, err + } + + if len(runs.Items) == 0 { + return nil, fmt.Errorf("no pipelineruns found in namespace %s", opt.cliparams.Namespace()) + } + + // Filter runs that use resolvers and find the latest + var filteredRuns []v1.PipelineRun + for _, run := range runs.Items { + if run.Spec.PipelineRef != nil && run.Spec.PipelineRef.ResolverRef.Resolver != "" { + // If resolvertype is specified, filter by that resolver type + if opt.ResolverType == "" || string(run.Spec.PipelineRef.ResolverRef.Resolver) == opt.ResolverType { + filteredRuns = append(filteredRuns, run) + } + } + } + + if len(filteredRuns) == 0 { + if opt.ResolverType != "" { + return nil, fmt.Errorf("no pipelineruns with resolver type '%s' found in namespace %s", opt.ResolverType, opt.cliparams.Namespace()) + } + return nil, fmt.Errorf("no pipelineruns with resolvers found in namespace %s", opt.cliparams.Namespace()) + } + + latest := filteredRuns[0] + for _, run := range filteredRuns { + if run.CreationTimestamp.Time.After(latest.CreationTimestamp.Time) { + latest = run + } + } + + // Convert v1 to v1beta1 + var pipelinerunBeta v1beta1.PipelineRun + err = pipelinerunBeta.ConvertFrom(context.Background(), &latest) + if err != nil { + return nil, err + } + + return &pipelinerunBeta, nil +} + +func (opt *startOptions) runWithRemoteResolver(cs *cli.Clients, pipelineName string) error { + // Find the latest PipelineRun with any resolver type + lastPipelineRun, err := opt.getLastPipelineRunWithAnyResolver(cs, pipelineName) + if err != nil { + return err + } + + // Create new PipelineRun based on the last one + objMeta := metav1.ObjectMeta{ + Namespace: opt.cliparams.Namespace(), + } + + switch { + case len(lastPipelineRun.ObjectMeta.GenerateName) > 0 && opt.PrefixName == "": + objMeta.GenerateName = lastPipelineRun.ObjectMeta.GenerateName + case opt.PrefixName == "": + objMeta.GenerateName = lastPipelineRun.ObjectMeta.Name + "-" + default: + objMeta.GenerateName = opt.PrefixName + "-" + } + + pr := &v1beta1.PipelineRun{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1beta1", + Kind: "PipelineRun", + }, + ObjectMeta: objMeta, + Spec: lastPipelineRun.Spec, + } + + // Reapply blank status in case PipelineRun used was cancelled + pr.Spec.Status = "" + + return opt.createAndRunPipelineRun(pr) +} + +// Get Latest Pipelinerun with any resolver type +// If pipeline name find then first it filters +// pipelinrun by give pipeline name and will return latest pipelinerun +func (opt *startOptions) getLastPipelineRunWithAnyResolver(cs *cli.Clients, pipelineName string) (*v1beta1.PipelineRun, error) { + options := metav1.ListOptions{} + + // If pipeline name is provided, filter by that pipeline + if pipelineName != "" { + options = metav1.ListOptions{ + LabelSelector: fmt.Sprintf("tekton.dev/pipeline=%s", pipelineName), + } + } + + var runs *v1.PipelineRunList + err := actions.ListV1(pipelineRunGroupResource, cs, options, opt.cliparams.Namespace(), &runs) + if err != nil { + return nil, err + } + + if len(runs.Items) == 0 { + if pipelineName != "" { + return nil, fmt.Errorf("no pipelineruns found for pipeline %s in namespace %s", pipelineName, opt.cliparams.Namespace()) + } + return nil, fmt.Errorf("no pipelineruns found in namespace %s", opt.cliparams.Namespace()) + } + + // Filter runs that use any resolver (hub, git, http, cluster, bundles) + validResolvers := []string{"hub", "git", "http", "cluster", "bundles"} + var filteredRuns []v1.PipelineRun + for _, run := range runs.Items { + if run.Spec.PipelineRef != nil && run.Spec.PipelineRef.ResolverRef.Resolver != "" { + resolverType := string(run.Spec.PipelineRef.ResolverRef.Resolver) + for _, validResolver := range validResolvers { + if resolverType == validResolver { + filteredRuns = append(filteredRuns, run) + break + } + } + } + } + + if len(filteredRuns) == 0 { + if pipelineName != "" { + return nil, fmt.Errorf("no pipelineruns with resolvers found for pipeline %s in namespace %s", pipelineName, opt.cliparams.Namespace()) + } + return nil, fmt.Errorf("no pipelineruns with resolvers found in namespace %s", opt.cliparams.Namespace()) + } + + // Find the latest one + latest := filteredRuns[0] + for _, run := range filteredRuns { + if run.CreationTimestamp.Time.After(latest.CreationTimestamp.Time) { + latest = run + } + } + + // Convert v1 to v1beta1 + var pipelinerunBeta v1beta1.PipelineRun + err = pipelinerunBeta.ConvertFrom(context.Background(), &latest) + if err != nil { + return nil, err + } + + return &pipelinerunBeta, nil +} + +func (opt *startOptions) createAndRunPipelineRun(pr *v1beta1.PipelineRun) error { + cs, err := opt.cliparams.Clients() + if err != nil { + return err + } + + // Apply common configurations + if opt.PrefixName != "" { + pr.ObjectMeta.GenerateName = opt.PrefixName + "-" + } + + if opt.TimeOut != "" { + timeoutDuration, err := time.ParseDuration(opt.TimeOut) + if err != nil { + return err + } + pr.Spec.Timeouts = &v1beta1.TimeoutFields{ + Pipeline: &metav1.Duration{Duration: timeoutDuration}, + } + } + + if opt.TasksTimeOut != "" || opt.PipelineTimeOut != "" || opt.FinallyTimeOut != "" { + if err := opt.getTimeouts(pr); err != nil { + return err + } + } + + labels, err := labels.MergeLabels(pr.ObjectMeta.Labels, opt.Labels) + if err != nil { + return err + } + pr.ObjectMeta.Labels = labels + + param, err := params.MergeParam(pr.Spec.Params, opt.Params) + if err != nil { + return err + } + pr.Spec.Params = param + + workspaces, err := workspaces.Merge(pr.Spec.Workspaces, opt.Workspaces, cs.HTTPClient) + if err != nil { + return err + } + pr.Spec.Workspaces = workspaces + + if err := mergeSvc(pr, opt.ServiceAccounts); err != nil { + return err + } + + if len(opt.ServiceAccountName) > 0 { + pr.Spec.ServiceAccountName = opt.ServiceAccountName + } + + podTemplateLocation := opt.PodTemplate + if podTemplateLocation != "" { + podTemplate, err := pods.ParsePodTemplate(cs.HTTPClient, podTemplateLocation, file.IsYamlFile(), fmt.Errorf("invalid file format for %s: .yaml or .yml file extension and format required", podTemplateLocation)) + if err != nil { + return err + } + pr.Spec.PodTemplate = &podTemplate + } + + if opt.DryRun { + format := strings.ToLower(opt.Output) + if format == "name" { + fmt.Fprintf(opt.stream.Out, "%s\n", pr.GetName()) + return nil + } + gvr, err := actions.GetGroupVersionResource(pipelineRunGroupResource, cs.Tekton.Discovery()) + if err != nil { + return err + } + if gvr.Version == "v1" { + var prv1 v1.PipelineRun + err = pr.ConvertTo(context.Background(), &prv1) + if err != nil { + return err + } + prv1.Kind = "PipelineRun" + prv1.APIVersion = "tekton.dev/v1" + return printPipelineRun(opt.Output, opt.stream, &prv1) + } + return printPipelineRun(opt.Output, opt.stream, pr) + } + + prCreated, err := pipelinerun.Create(cs, pr, metav1.CreateOptions{}, opt.cliparams.Namespace()) + if err != nil { + return err + } + + if opt.Output != "" { + format := strings.ToLower(opt.Output) + if format == "name" { + fmt.Fprintf(opt.stream.Out, "%s\n", prCreated.GetName()) + return nil + } + gvr, err := actions.GetGroupVersionResource(pipelineRunGroupResource, cs.Tekton.Discovery()) + if err != nil { + return err + } + if gvr.Version == "v1" { + var prv1 v1.PipelineRun + err = prCreated.ConvertTo(context.Background(), &prv1) + if err != nil { + return err + } + prv1.Kind = "PipelineRun" + prv1.APIVersion = "tekton.dev/v1" + return printPipelineRun(opt.Output, opt.stream, &prv1) + } + return printPipelineRun(opt.Output, opt.stream, prCreated) + } + + fmt.Fprintf(opt.stream.Out, "PipelineRun started: %s\n", prCreated.Name) + if !opt.ShowLog { + inOrderString := "\nIn order to track the PipelineRun progress run:\ntkn pipelinerun " + if opt.TektonOptions.Context != "" { + inOrderString += fmt.Sprintf("--context=%s ", opt.TektonOptions.Context) + } + inOrderString += fmt.Sprintf("logs %s -f -n %s\n", prCreated.Name, prCreated.Namespace) + + fmt.Fprint(opt.stream.Out, inOrderString) + return nil + } + + fmt.Fprintf(opt.stream.Out, "Waiting for logs to be available...\n") + runLogOpts := &options.LogOptions{ + PipelineRunName: prCreated.Name, + Stream: opt.stream, + Follow: true, + Prefixing: true, + Params: opt.cliparams, + AllSteps: false, + ExitWithPrError: opt.ExitWithPrError, + } + return prcmd.Run(runLogOpts) +} + func (opt *startOptions) startPipeline(pipelineStart *v1beta1.Pipeline) error { cs, err := opt.cliparams.Clients() if err != nil { diff --git a/pkg/cmd/pipeline/start_test.go b/pkg/cmd/pipeline/start_test.go index 64a5ec69e7..444fc2074f 100644 --- a/pkg/cmd/pipeline/start_test.go +++ b/pkg/cmd/pipeline/start_test.go @@ -2912,3 +2912,479 @@ func Test_start_pipeline_with_skip_optional_workspace_flag_v1beta1(t *testing.T) expected := "PipelineRun started: random\n\nIn order to track the PipelineRun progress run:\ntkn pipelinerun logs random -f -n ns\n" test.AssertOutput(t, expected, got) } + +func TestPipelineStart_WithResolver(t *testing.T) { + pipelineName := "test-pipeline" + + seedData, _ := test.SeedV1beta1TestData(t, test.Data{}) + cs := pipelinetest.Clients{ + Pipeline: seedData.Pipeline, + Kube: seedData.Kube, + } + cs.Pipeline.Resources = cb.APIResourceList("v1beta1", []string{"pipeline", "pipelinerun"}) + cs.Pipeline.PrependReactor("create", "pipelineruns", func(action k8stest.Action) (bool, runtime.Object, error) { + create := action.(k8stest.CreateAction) + pr := create.GetObject().(*v1beta1.PipelineRun) + + // Verify that the PipelineRun has the correct resolver configuration + if pr.Spec.PipelineRef == nil { + t.Errorf("Expected PipelineRef to be set") + } + if pr.Spec.PipelineRef.ResolverRef.Resolver != "git" { + t.Errorf("Expected resolver to be 'git', got %s", pr.Spec.PipelineRef.ResolverRef.Resolver) + } + + // Check that the name parameter is set correctly + found := false + for _, param := range pr.Spec.PipelineRef.ResolverRef.Params { + if param.Name == "name" && param.Value.StringVal == pipelineName { + found = true + break + } + } + if !found { + t.Errorf("Expected name parameter to be set to %s", pipelineName) + } + + pr.Name = "test-pipeline-run-123" + return true, pr, nil + }) + + objs := []runtime.Object{} + _, tdc := newV1beta1PipelineClient(objs...) + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + + pipeline := Command(p) + got, _ := test.ExecuteCommand(pipeline, "start", pipelineName, + "--resolvertype=git", + "-n", "ns", + ) + + expected := "PipelineRun started: random\n\nIn order to track the PipelineRun progress run:\ntkn pipelinerun logs random -f -n ns\n" + test.AssertOutput(t, expected, got) +} + +func TestPipelineStart_WithRemoteResolver(t *testing.T) { + pipelineName := "test-pipeline" + + // The remote resolver now looks for existing PipelineRuns with resolvers + // So we expect it to fail when no such PipelineRuns exist + seedData, _ := test.SeedV1beta1TestData(t, test.Data{}) + cs := pipelinetest.Clients{ + Pipeline: seedData.Pipeline, + Kube: seedData.Kube, + } + cs.Pipeline.Resources = cb.APIResourceList("v1beta1", []string{"pipeline", "pipelinerun"}) + + objs := []runtime.Object{} + _, tdc := newV1beta1PipelineClient(objs...) + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + + pipeline := Command(p) + _, err = test.ExecuteCommand(pipeline, "start", pipelineName, + "--resolvertype=remote", + "-n", "ns", + ) + + // Should fail because no PipelineRuns with resolvers exist for the specified pipeline + if err == nil { + t.Errorf("Expected error but got none") + } + expectedErr := "no pipelineruns found for pipeline test-pipeline in namespace ns" + if !strings.Contains(err.Error(), expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", expectedErr, err.Error()) + } +} + +func TestPipelineStart_ComprehensiveResolverValidation(t *testing.T) { + tests := []struct { + name string + args []string + expectedErr string + description string + }{ + // Invalid resolver type validation + { + name: "invalid resolver type", + args: []string{"start", "--resolvertype=invalid"}, + expectedErr: "invalid resolvertype 'invalid'. Valid values are: hub, git, http, cluster, bundles, remote", + description: "Should reject invalid resolver types", + }, + + // No flags scenarios + { + name: "no flags without pipeline name", + args: []string{"start"}, + expectedErr: "missing Pipeline name", + description: "Should require pipeline name when no flags are provided", + }, + { + name: "no flags with pipeline name should work", + args: []string{"start", "test-pipeline"}, + expectedErr: "Pipeline name test-pipeline does not exist in namespace", + description: "Should work with pipeline name and no flags (normal behavior)", + }, + + // --resolvertype only scenarios (without --last) + { + name: "hub resolver without pipeline name", + args: []string{"start", "--resolvertype=hub"}, + expectedErr: "pipeline name is required when using --resolvertype flag", + description: "Hub resolver should require pipeline name", + }, + { + name: "git resolver without pipeline name", + args: []string{"start", "--resolvertype=git"}, + expectedErr: "pipeline name is required when using --resolvertype flag", + description: "Git resolver should require pipeline name", + }, + { + name: "http resolver without pipeline name", + args: []string{"start", "--resolvertype=http"}, + expectedErr: "pipeline name is required when using --resolvertype flag", + description: "HTTP resolver should require pipeline name", + }, + { + name: "cluster resolver without pipeline name", + args: []string{"start", "--resolvertype=cluster"}, + expectedErr: "pipeline name is required when using --resolvertype flag", + description: "Cluster resolver should require pipeline name", + }, + { + name: "bundles resolver without pipeline name", + args: []string{"start", "--resolvertype=bundles"}, + expectedErr: "pipeline name is required when using --resolvertype flag", + description: "bundles resolver should require pipeline name", + }, + { + name: "remote resolver without pipeline name should work", + args: []string{"start", "--resolvertype=remote"}, + expectedErr: "no pipelineruns found in namespace", + description: "Remote resolver should NOT require pipeline name (special case)", + }, + + // --resolvertype with pipeline name (without --last) + { + name: "hub resolver with pipeline name", + args: []string{"start", "test-pipeline", "--resolvertype=hub"}, + expectedErr: "PipelineRun started:", // Should succeed and create PipelineRun + description: "Hub resolver with pipeline name should work", + }, + { + name: "git resolver with pipeline name", + args: []string{"start", "test-pipeline", "--resolvertype=git"}, + expectedErr: "PipelineRun started:", // Should succeed and create PipelineRun + description: "Git resolver with pipeline name should work", + }, + { + name: "remote resolver with pipeline name", + args: []string{"start", "test-pipeline", "--resolvertype=remote"}, + expectedErr: "no pipelineruns found for pipeline test-pipeline", + description: "Remote resolver with pipeline name should look for existing runs", + }, + + // --last only scenarios (without --resolvertype) + { + name: "last flag without pipeline name", + args: []string{"start", "--last"}, + expectedErr: "missing Pipeline name", + description: "Last flag without pipeline name requires pipeline name in current implementation", + }, + { + name: "last flag with pipeline name", + args: []string{"start", "test-pipeline", "--last"}, + expectedErr: "Pipeline name test-pipeline does not exist in namespace", + description: "Last flag with pipeline name should work", + }, + + // --resolvertype and --last together + { + name: "hub resolver with last flag without pipeline name", + args: []string{"start", "--resolvertype=hub", "--last"}, + expectedErr: "no pipelineruns found in namespace", + description: "Resolver + last without pipeline name should work (optional pipeline name)", + }, + { + name: "git resolver with last flag and pipeline name", + args: []string{"start", "test-pipeline", "--resolvertype=git", "--last"}, + expectedErr: "no pipelineruns related to pipeline test-pipeline found in namespace", + description: "Resolver + last with pipeline name should work", + }, + { + name: "remote resolver with last flag without pipeline name", + args: []string{"start", "--resolvertype=remote", "--last"}, + expectedErr: "no pipelineruns found in namespace", + description: "Remote resolver + last without pipeline name should work", + }, + { + name: "remote resolver with last flag and pipeline name", + args: []string{"start", "test-pipeline", "--resolvertype=remote", "--last"}, + expectedErr: "no pipelineruns found for pipeline test-pipeline", + description: "Remote resolver + last with pipeline name should work", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Logf("Testing: %s", tt.description) + + seedData, _ := test.SeedV1beta1TestData(t, test.Data{}) + cs := pipelinetest.Clients{ + Pipeline: seedData.Pipeline, + Kube: seedData.Kube, + } + cs.Pipeline.Resources = cb.APIResourceList("v1beta1", []string{"pipeline", "pipelinerun"}) + + // Add reactor for successful PipelineRun creation cases + cs.Pipeline.PrependReactor("create", "pipelineruns", func(action k8stest.Action) (bool, runtime.Object, error) { + create := action.(k8stest.CreateAction) + pr := create.GetObject().(*v1beta1.PipelineRun) + pr.Name = "random" + return true, pr, nil + }) + + objs := []runtime.Object{} + _, tdc := newV1beta1PipelineClient(objs...) + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + + pipeline := Command(p) + got, err := test.ExecuteCommand(pipeline, tt.args...) + + // Check if this is a success case (expectedErr contains "PipelineRun started:") + if strings.Contains(tt.expectedErr, "PipelineRun started:") { + // This should succeed + if err != nil { + t.Errorf("Expected success but got error: %s", err.Error()) + } + if !strings.Contains(got, tt.expectedErr) { + t.Errorf("Expected output to contain '%s', got '%s'", tt.expectedErr, got) + } + } else { + // This should fail with specific error + if err == nil { + t.Errorf("Expected error but got none. Output: %s", got) + } else if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", tt.expectedErr, err.Error()) + } + } + }) + } +} + +func TestPipelineStart_RemoteResolverFindsLatestAcrossAllPipelines(t *testing.T) { + // Test that remote resolver attempts to find PipelineRuns with resolvers + // This test verifies the error case when no PipelineRuns with resolvers exist + cs, _ := test.SeedV1beta1TestData(t, test.Data{}) + cs.Pipeline.Resources = cb.APIResourceList("v1beta1", []string{"pipeline", "pipelinerun"}) + + objs := []runtime.Object{} + _, tdc := newV1beta1PipelineClient(objs...) + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + + pipeline := Command(p) + _, err = test.ExecuteCommand(pipeline, "start", + "--resolvertype=remote", + "-n", "ns", + ) + + // Should fail because no PipelineRuns exist + if err == nil { + t.Errorf("Expected error but got none") + } + expectedErr := "no pipelineruns found in namespace ns" + if !strings.Contains(err.Error(), expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", expectedErr, err.Error()) + } +} + +func TestPipelineStart_RemoteResolverIgnoresNonResolverRuns(t *testing.T) { + // Test that remote resolver ignores PipelineRuns that don't use resolvers + prs := []*v1beta1.PipelineRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "direct-pipeline-run", + Namespace: "ns", + Labels: map[string]string{"tekton.dev/pipeline": "direct-pipeline"}, + CreationTimestamp: metav1.Time{Time: time.Now().Add(-1 * time.Hour)}, + }, + Spec: v1beta1.PipelineRunSpec{ + PipelineRef: &v1beta1.PipelineRef{ + Name: "direct-pipeline", // No resolver + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "inline-pipeline-run", + Namespace: "ns", + CreationTimestamp: metav1.Time{Time: time.Now().Add(-30 * time.Minute)}, + }, + Spec: v1beta1.PipelineRunSpec{ + PipelineSpec: &v1beta1.PipelineSpec{}, // Inline spec, no resolver + }, + }, + } + + cs, _ := test.SeedV1beta1TestData(t, test.Data{ + PipelineRuns: prs, + }) + cs.Pipeline.Resources = cb.APIResourceList("v1beta1", []string{"pipeline", "pipelinerun"}) + + objs := []runtime.Object{} + _, tdc := newV1beta1PipelineClient(objs...) + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + + pipeline := Command(p) + _, err = test.ExecuteCommand(pipeline, "start", + "--resolvertype=remote", + "-n", "ns", + ) + + // Should fail because no PipelineRuns with resolvers exist + if err == nil { + t.Errorf("Expected error but got none") + } + expectedErr := "no pipelineruns found in namespace ns" + if !strings.Contains(err.Error(), expectedErr) { + t.Errorf("Expected error to contain '%s', got '%s'", expectedErr, err.Error()) + } +} +func TestPipelineStart_RemoteResolverValidationLogic(t *testing.T) { + // Test that remote resolver validation logic works correctly + tests := []struct { + name string + args []string + expectError bool + errorMsg string + }{ + { + name: "remote resolver without pipeline name should not error in validation", + args: []string{"start", "--resolvertype=remote"}, + expectError: true, // Will error later when no PipelineRuns found, not in validation + errorMsg: "no pipelineruns found", + }, + { + name: "remote resolver with pipeline name should work", + args: []string{"start", "test-pipeline", "--resolvertype=remote"}, + expectError: true, // Will error when no PipelineRuns found for pipeline + errorMsg: "no pipelineruns found for pipeline test-pipeline", + }, + { + name: "remote resolver with last flag should work", + args: []string{"start", "--resolvertype=remote", "--last"}, + expectError: true, // Will error when no PipelineRuns found + errorMsg: "no pipelineruns found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs, _ := test.SeedV1beta1TestData(t, test.Data{}) + cs.Pipeline.Resources = cb.APIResourceList("v1beta1", []string{"pipeline", "pipelinerun"}) + objs := []runtime.Object{} + _, tdc := newV1beta1PipelineClient(objs...) + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + + pipeline := Command(p) + _, err = test.ExecuteCommand(pipeline, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error to contain '%s', got '%s'", tt.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %s", err.Error()) + } + } + }) + } +} + +func TestPipelineStart_RemoteResolverVsOtherResolvers(t *testing.T) { + // Test that remote resolver behaves differently from other resolvers + tests := []struct { + name string + resolverType string + args []string + expectError bool + errorMsg string + }{ + { + name: "git resolver requires pipeline name", + resolverType: "git", + args: []string{"start", "--resolvertype=git"}, + expectError: true, + errorMsg: "pipeline name is required when using --resolvertype flag", + }, + { + name: "hub resolver requires pipeline name", + resolverType: "hub", + args: []string{"start", "--resolvertype=hub"}, + expectError: true, + errorMsg: "pipeline name is required when using --resolvertype flag", + }, + { + name: "remote resolver does not require pipeline name", + resolverType: "remote", + args: []string{"start", "--resolvertype=remote"}, + expectError: true, // Different error - no PipelineRuns found + errorMsg: "no pipelineruns found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs, _ := test.SeedV1beta1TestData(t, test.Data{}) + cs.Pipeline.Resources = cb.APIResourceList("v1beta1", []string{"pipeline", "pipelinerun"}) + objs := []runtime.Object{} + _, tdc := newV1beta1PipelineClient(objs...) + dc, err := tdc.Client() + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + p := &test.Params{Tekton: cs.Pipeline, Kube: cs.Kube, Dynamic: dc} + + pipeline := Command(p) + _, err = test.ExecuteCommand(pipeline, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error to contain '%s', got '%s'", tt.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %s", err.Error()) + } + } + }) + } +}