From 80ca85032bc62bda460f5cc25bc9456efc67e545 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sat, 27 Jun 2026 21:19:42 +0530 Subject: [PATCH 1/3] feat: add --json flag to pipeline and task start commands --- pkg/cmd/pipeline/start.go | 70 ++++++++++++++++++ pkg/cmd/pipeline/start_test.go | 123 +++++++++++++++++++++++++++++--- pkg/cmd/task/start.go | 65 +++++++++++++++++ pkg/cmd/task/start_test.go | 125 ++++++++++++++++++++++++++++++--- 4 files changed, 366 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/pipeline/start.go b/pkg/cmd/pipeline/start.go index 90707eda29..7cf56d0fde 100644 --- a/pkg/cmd/pipeline/start.go +++ b/pkg/cmd/pipeline/start.go @@ -19,6 +19,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "os" "strings" @@ -82,6 +83,32 @@ type startOptions struct { PodTemplate string SkipOptionalWorkspace bool ResolverType string + JSONSpec string +} + +// readJSONSpec resolves a --json flag value to raw bytes. +// Supported forms: +// - "-" read from stdin +// - "@path" read from the file at path +// - anything else is treated as a literal JSON string +func readJSONSpec(raw string, stdin io.Reader) ([]byte, error) { + switch { + case raw == "-": + b, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("reading JSON spec from stdin: %w", err) + } + return b, nil + case strings.HasPrefix(raw, "@"): + path := raw[1:] + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading JSON spec from file %q: %w", path, err) + } + return b, nil + default: + return []byte(raw), nil + } } func startCommand(p cli.Params) *cobra.Command { @@ -265,6 +292,8 @@ For passing the workspaces via flags: }, ) + c.Flags().StringVar(&opt.JSONSpec, "json", "", "PipelineRun spec as JSON (inline, '-' for stdin, or '@path' for a file)") + return c } @@ -572,7 +601,43 @@ func (opt *startOptions) createObjectMeta(lastPipelineRun *v1beta1.PipelineRun, } // configurePipelineRun applies common configurations to a PipelineRun +func (opt *startOptions) applyJSONSpec(pr *v1beta1.PipelineRun, stdin io.Reader) error { + if opt.JSONSpec == "" { + return nil + } + raw, err := readJSONSpec(opt.JSONSpec, stdin) + if err != nil { + return err + } + var spec v1beta1.PipelineRunSpec + if err := json.Unmarshal(raw, &spec); err != nil { + return fmt.Errorf("invalid JSON spec: %w", err) + } + + if spec.Params != nil { + pr.Spec.Params = append(spec.Params, pr.Spec.Params...) + } + if spec.Workspaces != nil { + pr.Spec.Workspaces = append(spec.Workspaces, pr.Spec.Workspaces...) + } + if spec.ServiceAccountName != "" && pr.Spec.ServiceAccountName == "" { + pr.Spec.ServiceAccountName = spec.ServiceAccountName + } + if spec.Timeouts != nil && pr.Spec.Timeouts == nil { + pr.Spec.Timeouts = spec.Timeouts + } + if spec.PodTemplate != nil && pr.Spec.PodTemplate == nil { + pr.Spec.PodTemplate = spec.PodTemplate + } + return nil +} + func (opt *startOptions) configurePipelineRun(pr *v1beta1.PipelineRun, cs *cli.Clients) error { + + if err := opt.applyJSONSpec(pr, os.Stdin); err != nil { + return err + } + // Apply timeouts if opt.TimeOut != "" { timeoutDuration, err := time.ParseDuration(opt.TimeOut) @@ -809,6 +874,11 @@ func (opt *startOptions) startPipeline(pipelineStart *v1beta1.Pipeline) error { func (opt *startOptions) getInput(pipeline *v1beta1.Pipeline) error { params.FilterParamsByType(pipeline.Spec.Params) + + if opt.JSONSpec != "" { + return nil + } + if !opt.Last && opt.UsePipelineRun == "" { skipParams, err := params.ParseParams(opt.Params) if err != nil { diff --git a/pkg/cmd/pipeline/start_test.go b/pkg/cmd/pipeline/start_test.go index b69ce715b8..d7ed494d98 100644 --- a/pkg/cmd/pipeline/start_test.go +++ b/pkg/cmd/pipeline/start_test.go @@ -16,6 +16,7 @@ package pipeline import ( "fmt" + "os" "reflect" "strings" "testing" @@ -296,14 +297,15 @@ func TestPipelineStart_ExecuteCommand_v1beta1(t *testing.T) { c7 := &test.Params{Tekton: cs7.Pipeline, Kube: cs7.Kube, Dynamic: dc7, Clock: clock} testParams := []struct { - name string - command []string - namespace string - input *test.Params - wantError bool - hasPrefix bool - want string - goldenFile bool + name string + command []string + namespace string + input *test.Params + wantError bool + hasPrefix bool + want string + wantContains string + goldenFile bool }{ { name: "Invalid namespace", @@ -848,6 +850,57 @@ func TestPipelineStart_ExecuteCommand_v1beta1(t *testing.T) { wantError: false, goldenFile: true, }, + { + name: "--json inline params appear in dry-run output", + command: []string{ + "start", "test-pipeline", + "-n", "ns", + "--json", `{"params":[{"name":"pipeline-param","value":"from-json"}]}`, + "--dry-run", + "--output", "yaml", + }, + input: c2, + wantContains: "from-json", + }, + { + name: "--json with --param flag both apply", + command: []string{ + "start", "test-pipeline", + "-n", "ns", + "--json", `{"params":[{"name":"pipeline-param","value":"from-json"}]}`, + "-p=rev-param=from-flag", + "--dry-run", + "--output", "yaml", + }, + input: c2, + wantContains: "from-flag", + }, + { + name: "--json with malformed JSON returns error", + command: []string{ + "start", "test-pipeline", + "-n", "ns", + "--json", `{broken`, + "--dry-run", + }, + input: c2, + wantError: true, + hasPrefix: true, + want: "invalid JSON spec", + }, + { + name: "--json with missing file returns error", + command: []string{ + "start", "test-pipeline", + "-n", "ns", + "--json", "@/no/such/file.json", + "--dry-run", + }, + input: c2, + wantError: true, + hasPrefix: true, + want: "reading JSON spec from file", + }, } for _, tp := range testParams { @@ -874,6 +927,10 @@ func TestPipelineStart_ExecuteCommand_v1beta1(t *testing.T) { } if tp.goldenFile { golden.Assert(t, got, strings.ReplaceAll(fmt.Sprintf("%s.golden", t.Name()), "/", "-")) + } else if tp.wantContains != "" { + if !strings.Contains(got, tp.wantContains) { + t.Errorf("expected output to contain %q, got:\n%s", tp.wantContains, got) + } } else { test.AssertOutput(t, tp.want, got) } @@ -3422,3 +3479,53 @@ func TestPipelineStart_RemoteResolverVsOtherResolvers(t *testing.T) { }) } } + +func TestReadJSONSpec(t *testing.T) { + const payload = `{"params":[{"name":"repo","value":"x"}]}` + + t.Run("inline JSON", func(t *testing.T) { + b, err := readJSONSpec(payload, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(b) != payload { + t.Fatalf("got %q, want %q", b, payload) + } + }) + + t.Run("stdin (-)", func(t *testing.T) { + r := strings.NewReader(payload) + b, err := readJSONSpec("-", r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(b) != payload { + t.Fatalf("got %q, want %q", b, payload) + } + }) + + t.Run("file (@path)", func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "spec-*.json") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(payload); err != nil { + t.Fatal(err) + } + f.Close() + b, err := readJSONSpec("@"+f.Name(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(b) != payload { + t.Fatalf("got %q, want %q", b, payload) + } + }) + + t.Run("missing file returns error", func(t *testing.T) { + _, err := readJSONSpec("@/nonexistent/path.json", nil) + if err == nil { + t.Fatal("expected error for missing file") + } + }) +} diff --git a/pkg/cmd/task/start.go b/pkg/cmd/task/start.go index 6586dbbb95..795388987c 100644 --- a/pkg/cmd/task/start.go +++ b/pkg/cmd/task/start.go @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "errors" + "io" "os" "strings" "time" @@ -79,6 +80,59 @@ type startOptions struct { PodTemplate string SkipOptionalWorkspace bool remoteOptions bundle.RemoteOptions + JSONSpec string +} + +// readJSONSpec resolves a --json flag value to raw bytes. +// Supported forms: +// - "-" read from stdin +// - "@path" read from the file at path +// - anything else is treated as a literal JSON string +func readJSONSpec(raw string, stdin io.Reader) ([]byte, error) { + switch { + case raw == "-": + b, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("reading JSON spec from stdin: %w", err) + } + return b, nil + case strings.HasPrefix(raw, "@"): + path := raw[1:] + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading JSON spec from file %q: %w", path, err) + } + return b, nil + default: + return []byte(raw), nil + } +} + +func applyTaskRunJSONSpec(tr *v1beta1.TaskRun, raw string, stdin io.Reader) error { + b, err := readJSONSpec(raw, stdin) + if err != nil { + return err + } + var spec v1beta1.TaskRunSpec + if err := json.Unmarshal(b, &spec); err != nil { + return fmt.Errorf("invalid JSON spec: %w", err) + } + if spec.Params != nil { + tr.Spec.Params = append(spec.Params, tr.Spec.Params...) + } + if spec.Workspaces != nil { + tr.Spec.Workspaces = append(spec.Workspaces, tr.Spec.Workspaces...) + } + if spec.ServiceAccountName != "" && tr.Spec.ServiceAccountName == "" { + tr.Spec.ServiceAccountName = spec.ServiceAccountName + } + if spec.Timeout != nil && tr.Spec.Timeout == nil { + tr.Spec.Timeout = spec.Timeout + } + if spec.PodTemplate != nil && tr.Spec.PodTemplate == nil { + tr.Spec.PodTemplate = spec.PodTemplate + } + return nil } // NameArg validates that the first argument is a valid task name @@ -243,6 +297,7 @@ For passing the workspaces via flags: c.Flags().BoolVarP(&opt.UseParamDefaults, "use-param-defaults", "", false, "use default parameter values without prompting for input") c.Flags().StringVar(&opt.PodTemplate, "pod-template", "", "local or remote file containing a PodTemplate definition") c.Flags().BoolVarP(&opt.SkipOptionalWorkspace, "skip-optional-workspace", "", false, "skips the prompt for optional workspaces") + c.Flags().StringVar(&opt.JSONSpec, "json", "", "TaskRun spec as JSON (inline, '-' for stdin, or '@path' for a file)") bundle.AddRemoteFlags(c.Flags(), &opt.remoteOptions) return c @@ -354,6 +409,12 @@ func startTask(opt startOptions, args []string) error { } } + if opt.JSONSpec != "" { + if err := applyTaskRunJSONSpec(tr, opt.JSONSpec, os.Stdin); err != nil { + return err + } + } + if err := opt.getInputs(); err != nil { return err } @@ -503,6 +564,10 @@ func printTaskRun(output string, s *cli.Stream, tr interface{}) error { } func (opt *startOptions) getInputs() error { + if opt.JSONSpec != "" { + return nil + } + if opt.Last && opt.UseTaskRun != "" { fmt.Fprintf(opt.stream.Err, "option --last and option --use-taskrun are not compatible \n") return nil diff --git a/pkg/cmd/task/start_test.go b/pkg/cmd/task/start_test.go index 808e3c4fdf..a0186daea0 100644 --- a/pkg/cmd/task/start_test.go +++ b/pkg/cmd/task/start_test.go @@ -16,6 +16,7 @@ package task import ( "fmt" + "os" "reflect" "strings" "testing" @@ -1841,15 +1842,16 @@ func TestTaskStart_ExecuteCommand_v1beta1(t *testing.T) { ) testParams := []struct { - name string - command []string - namespace string - dynamic dynamic.Interface - input test.Clients - wantError bool - hasPrefix bool - want string - goldenFile bool + name string + command []string + namespace string + dynamic dynamic.Interface + input test.Clients + wantError bool + hasPrefix bool + want string + wantContains string + goldenFile bool }{ { name: "Dry Run with invalid output", @@ -2059,6 +2061,57 @@ func TestTaskStart_ExecuteCommand_v1beta1(t *testing.T) { wantError: false, goldenFile: true, }, + { + name: "--json inline params appear in dry-run output", + command: []string{"start", "task-1", + "-n", "ns", + "--json", `{"params":[{"name":"myarg","value":"from-json"}]}`, + "--dry-run", + "--output", "yaml", + }, + dynamic: dc, + input: cs, + wantContains: "from-json", + }, + { + name: "--json with --param flag both apply", + command: []string{"start", "task-1", + "-n", "ns", + "--json", `{"params":[{"name":"myarg","value":"from-json"}]}`, + "-p=task-param=from-flag", + "--dry-run", + "--output", "yaml", + }, + dynamic: dc, + input: cs, + wantContains: "from-flag", + }, + { + name: "--json with malformed JSON returns error", + command: []string{"start", "task-1", + "-n", "ns", + "--json", `{broken`, + "--dry-run", + }, + dynamic: dc, + input: cs, + wantError: true, + hasPrefix: true, + want: "invalid JSON spec", + }, + { + name: "--json with missing file returns error", + command: []string{"start", "task-1", + "-n", "ns", + "--json", "@/no/such/file.json", + "--dry-run", + }, + dynamic: dc, + input: cs, + wantError: true, + hasPrefix: true, + want: "reading JSON spec from file", + }, } for _, tp := range testParams { @@ -2083,6 +2136,10 @@ func TestTaskStart_ExecuteCommand_v1beta1(t *testing.T) { } if tp.goldenFile { golden.Assert(t, got, strings.ReplaceAll(fmt.Sprintf("%s.golden", t.Name()), "/", "-")) + } else if tp.wantContains != "" { + if !strings.Contains(got, tp.wantContains) { + t.Errorf("expected output to contain %q, got:\n%s", tp.wantContains, got) + } } else { test.AssertOutput(t, tp.want, got) } @@ -2145,3 +2202,53 @@ func Test_start_task_with_skip_optional_workspace_flag_v1beta1(t *testing.T) { expected := "TaskRun started: \n\nIn order to track the TaskRun progress run:\ntkn taskrun logs -f -n ns\n" test.AssertOutput(t, expected, got) } + +func TestReadJSONSpec(t *testing.T) { + const payload = `{"params":[{"name":"arg","value":"val"}]}` + + t.Run("inline JSON", func(t *testing.T) { + b, err := readJSONSpec(payload, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(b) != payload { + t.Fatalf("got %q, want %q", b, payload) + } + }) + + t.Run("stdin (-)", func(t *testing.T) { + r := strings.NewReader(payload) + b, err := readJSONSpec("-", r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(b) != payload { + t.Fatalf("got %q, want %q", b, payload) + } + }) + + t.Run("file (@path)", func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "spec-*.json") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(payload); err != nil { + t.Fatal(err) + } + f.Close() + b, err := readJSONSpec("@"+f.Name(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(b) != payload { + t.Fatalf("got %q, want %q", b, payload) + } + }) + + t.Run("missing file returns error", func(t *testing.T) { + _, err := readJSONSpec("@/nonexistent/path.json", nil) + if err == nil { + t.Fatal("expected error for missing file") + } + }) +} From 00f2d013443ca2ade237aa110a4588411be22247 Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Sat, 27 Jun 2026 21:19:52 +0530 Subject: [PATCH 2/3] docs: add --json flag documentation for pipeline and task start commands --- docs/cmd/tkn_pipeline_start.md | 1 + docs/cmd/tkn_task_start.md | 1 + docs/man/man1/tkn-pipeline-start.1 | 4 ++++ docs/man/man1/tkn-task-start.1 | 4 ++++ 4 files changed, 10 insertions(+) diff --git a/docs/cmd/tkn_pipeline_start.md b/docs/cmd/tkn_pipeline_start.md index 2f8e2bfc40..48986cc0a9 100644 --- a/docs/cmd/tkn_pipeline_start.md +++ b/docs/cmd/tkn_pipeline_start.md @@ -67,6 +67,7 @@ my-csi-template and my-volume-claim-template) -f, --filename string local or remote file name containing a Pipeline definition to start a PipelineRun --finally-timeout string timeout for Finally TaskRuns -h, --help help for start + --json string PipelineRun spec as JSON (inline, '-' for stdin, or '@path' for a file) -l, --labels strings pass labels as label=value. -L, --last re-run the Pipeline using last PipelineRun values -o, --output string format of PipelineRun (yaml, json or name) diff --git a/docs/cmd/tkn_task_start.md b/docs/cmd/tkn_task_start.md index 8cde7c12a5..dfa0f07d20 100644 --- a/docs/cmd/tkn_task_start.md +++ b/docs/cmd/tkn_task_start.md @@ -60,6 +60,7 @@ For passing the workspaces via flags: -f, --filename string local or remote file name containing a Task definition to start a TaskRun -h, --help help for start -i, --image string use an oci bundle + --json string TaskRun spec as JSON (inline, '-' for stdin, or '@path' for a file) -l, --labels strings pass labels as label=value. -L, --last re-run the Task using last TaskRun values --output string format of TaskRun (yaml or json) diff --git a/docs/man/man1/tkn-pipeline-start.1 b/docs/man/man1/tkn-pipeline-start.1 index 3ca854fb61..bcbc9463b7 100644 --- a/docs/man/man1/tkn-pipeline-start.1 +++ b/docs/man/man1/tkn-pipeline-start.1 @@ -47,6 +47,10 @@ Parameters, at least those that have no default value \fB\-h\fP, \fB\-\-help\fP[=false] help for start +.PP +\fB\-\-json\fP="" + PipelineRun spec as JSON (inline, '\-' for stdin, or '@path' for a file) + .PP \fB\-l\fP, \fB\-\-labels\fP=[] pass labels as label=value. diff --git a/docs/man/man1/tkn-task-start.1 b/docs/man/man1/tkn-task-start.1 index eb7b8753d9..43a4b05e43 100644 --- a/docs/man/man1/tkn-task-start.1 +++ b/docs/man/man1/tkn-task-start.1 @@ -35,6 +35,10 @@ Start Tasks \fB\-i\fP, \fB\-\-image\fP="" use an oci bundle +.PP +\fB\-\-json\fP="" + TaskRun spec as JSON (inline, '\-' for stdin, or '@path' for a file) + .PP \fB\-l\fP, \fB\-\-labels\fP=[] pass labels as label=value. From 74503654c88362c1f481abe175b9e86db25bd05f Mon Sep 17 00:00:00 2001 From: Vikash Kumar Date: Tue, 30 Jun 2026 22:46:52 +0530 Subject: [PATCH 3/3] fix: move applyJSONSpec call after input collection --- pkg/cmd/pipeline/start.go | 13 ++++++++----- pkg/cmd/task/start.go | 12 ++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/pipeline/start.go b/pkg/cmd/pipeline/start.go index 7cf56d0fde..4993accf28 100644 --- a/pkg/cmd/pipeline/start.go +++ b/pkg/cmd/pipeline/start.go @@ -633,11 +633,6 @@ func (opt *startOptions) applyJSONSpec(pr *v1beta1.PipelineRun, stdin io.Reader) } func (opt *startOptions) configurePipelineRun(pr *v1beta1.PipelineRun, cs *cli.Clients) error { - - if err := opt.applyJSONSpec(pr, os.Stdin); err != nil { - return err - } - // Apply timeouts if opt.TimeOut != "" { timeoutDuration, err := time.ParseDuration(opt.TimeOut) @@ -788,6 +783,10 @@ func (opt *startOptions) createAndRunPipelineRun(pr *v1beta1.PipelineRun, pipeli return err } + if err := opt.applyJSONSpec(pr, os.Stdin); err != nil { + return err + } + // Configure the PipelineRun with common settings if err := opt.configurePipelineRun(pr, cs); err != nil { return err @@ -863,6 +862,10 @@ func (opt *startOptions) startPipeline(pipelineStart *v1beta1.Pipeline) error { pr.Spec.Status = "" } + if err := opt.applyJSONSpec(pr, os.Stdin); err != nil { + return err + } + // Configure the PipelineRun with common settings if err := opt.configurePipelineRun(pr, cs); err != nil { return err diff --git a/pkg/cmd/task/start.go b/pkg/cmd/task/start.go index 795388987c..ccff4a3e6d 100644 --- a/pkg/cmd/task/start.go +++ b/pkg/cmd/task/start.go @@ -409,12 +409,6 @@ func startTask(opt startOptions, args []string) error { } } - if opt.JSONSpec != "" { - if err := applyTaskRunJSONSpec(tr, opt.JSONSpec, os.Stdin); err != nil { - return err - } - } - if err := opt.getInputs(); err != nil { return err } @@ -432,6 +426,12 @@ func startTask(opt startOptions, args []string) error { } } + if opt.JSONSpec != "" { + if err := applyTaskRunJSONSpec(tr, opt.JSONSpec, os.Stdin); err != nil { + return err + } + } + if opt.PrefixName == "" && !opt.Last && opt.UseTaskRun == "" { tr.ObjectMeta.GenerateName = tname + "-run-" } else if opt.PrefixName != "" {