diff --git a/cmd/pipeline/cmd.go b/cmd/pipeline/cmd.go index 2c650b8d8..a9af0aaa7 100644 --- a/cmd/pipeline/cmd.go +++ b/cmd/pipeline/cmd.go @@ -19,9 +19,11 @@ import ( "github.com/datarobot/cli/cmd/pipeline/del" "github.com/datarobot/cli/cmd/pipeline/get" "github.com/datarobot/cli/cmd/pipeline/graph" + "github.com/datarobot/cli/cmd/pipeline/input" "github.com/datarobot/cli/cmd/pipeline/list" "github.com/datarobot/cli/cmd/pipeline/lock" "github.com/datarobot/cli/cmd/pipeline/run" + "github.com/datarobot/cli/cmd/pipeline/schedule" "github.com/datarobot/cli/cmd/pipeline/update" "github.com/datarobot/cli/cmd/pipeline/version" "github.com/datarobot/cli/internal/features" @@ -53,6 +55,8 @@ input payloads, runs, and recurring schedules.`, version.Cmd(), graph.Cmd(), run.Cmd(), + input.Cmd(), + schedule.Cmd(), ) return cmd diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go index db8a31203..752896a10 100644 --- a/cmd/pipeline/cmd_test.go +++ b/cmd/pipeline/cmd_test.go @@ -55,15 +55,17 @@ func TestCmd_HasExpectedSubcommands(t *testing.T) { cmd := Cmd() want := map[string]bool{ - "create": false, - "get": false, - "list": false, - "update": false, - "delete": false, - "lock": false, - "version": false, - "graph": false, - "run": false, + "create": false, + "get": false, + "list": false, + "update": false, + "delete": false, + "lock": false, + "version": false, + "graph": false, + "run": false, + "input": false, + "schedule": false, } for _, sub := range cmd.Commands() { diff --git a/cmd/pipeline/input/cmd.go b/cmd/pipeline/input/cmd.go new file mode 100644 index 000000000..f2e6955b0 --- /dev/null +++ b/cmd/pipeline/input/cmd.go @@ -0,0 +1,51 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package input + +import ( + "github.com/datarobot/cli/cmd/pipeline/input/create" + "github.com/datarobot/cli/cmd/pipeline/input/del" + "github.com/datarobot/cli/cmd/pipeline/input/get" + "github.com/datarobot/cli/cmd/pipeline/input/list" + "github.com/datarobot/cli/cmd/pipeline/input/update" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline input`. It groups the +// CRUD verbs that operate on pipeline input sets. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "input", + Short: "Manage pipeline input sets", + Long: `Manage input payloads bound to a pipeline. + +Inputs come in two scopes: + - draft : mutable; bound to the current draft of a pipeline + - locked : immutable; bound to a specific frozen version + +When --version is supplied, the locked scope is selected automatically. +Pass --scope=draft to be explicit.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + update.Cmd(), + del.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/input/cmd_test.go b/cmd/pipeline/input/cmd_test.go new file mode 100644 index 000000000..4b7923a46 --- /dev/null +++ b/cmd/pipeline/input/cmd_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package input + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "update": false, + "delete": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipeline/input/create/cmd.go b/cmd/pipeline/input/create/cmd.go new file mode 100644 index 000000000..26e688a6d --- /dev/null +++ b/cmd/pipeline/input/create/cmd.go @@ -0,0 +1,95 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "errors" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + fromFile string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "create []", + Short: "Create a pipeline input set", + Long: `Create an input payload for a pipeline. + +The payload must be a JSON object. The path to the JSON file can be +supplied either as a positional argument or via --from-file=. +Exactly one of the two must be provided. + +Scope is selected from the --scope/--version flags: + - no flags -> draft + - --version=N -> locked, version N (scope auto-set) + - --scope=draft -> draft + - --scope=locked --version=N -> locked, version N + +Example: + dr pipeline input create --pipeline ./payload.json + dr pipeline input create --pipeline --from-file=./payload.json + dr pipeline input create --pipeline --version=2 ./payload.json --output-format json`, + Args: cobra.MaximumNArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + payload, err := pipeline.ResolvePayload(args, fromFile) + if err != nil { + return err + } + + result, err := pipeline.CreateInput(flags.PipelineID, scope, version, payload) + if err != nil { + return err + } + + return pipeline.RenderInput(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/input/create/cmd_test.go b/cmd/pipeline/input/create/cmd_test.go new file mode 100644 index 000000000..3fa401fb1 --- /dev/null +++ b/cmd/pipeline/input/create/cmd_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "draft", "--version", "2", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestCmd_RejectsMissingPayload(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "from-file", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/input/del/cmd.go b/cmd/pipeline/input/del/cmd.go new file mode 100644 index 000000000..9e051c06a --- /dev/null +++ b/cmd/pipeline/input/del/cmd.go @@ -0,0 +1,94 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package del implements the `dr pipeline input delete` verb. The +// directory is named `del` rather than `delete` because the latter +// shadows Go's built-in delete() function in importing files. + +package del + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var flags scopeflag.Flags + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline input set", + Long: `Delete an input payload from a pipeline. + +Example: + dr pipeline input delete --pipeline + dr pipeline input delete --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + err = pipeline.DeleteInput(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleDeleteError(err, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted input: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "input_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + } + }) + + return cmd +} + +// handleDeleteError converts a 404 into a friendly informational message +// (returns nil) so the user does not see a stack-trace-style HTTP error +// for what is effectively a no-op. +func handleDeleteError(err error, id string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No input found with id: " + id)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/input/del/cmd_test.go b/cmd/pipeline/input/del/cmd_test.go new file mode 100644 index 000000000..3c6a1ffe8 --- /dev/null +++ b/cmd/pipeline/input/del/cmd_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "delete", Cmd().Name()) +} diff --git a/cmd/pipeline/input/get/cmd.go b/cmd/pipeline/input/get/cmd.go new file mode 100644 index 000000000..bc28f872c --- /dev/null +++ b/cmd/pipeline/input/get/cmd.go @@ -0,0 +1,94 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline input set", + Long: `Display the full payload and metadata for a single input set. + +Example: + dr pipeline input get --pipeline + dr pipeline input get --pipeline --version=2 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.GetInput(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + return pipeline.RenderInput(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "input_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} + +func handleGetError(err error, inputID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No input found with id: " + inputID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/input/get/cmd_test.go b/cmd/pipeline/input/get/cmd_test.go new file mode 100644 index 000000000..c0e25f65b --- /dev/null +++ b/cmd/pipeline/input/get/cmd_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleGetError(httpErr, "in-1")) +} + +func TestHandleGetError_PropagatesOther(t *testing.T) { + err := handleGetError(errors.New("boom"), "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipeline/input/list/cmd.go b/cmd/pipeline/input/list/cmd.go new file mode 100644 index 000000000..0140dddbd --- /dev/null +++ b/cmd/pipeline/input/list/cmd.go @@ -0,0 +1,84 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline input sets", + Long: `List input payloads for a pipeline. + +Scope is selected the same way as create: + - no flags -> draft + - --version=N -> locked, version N (scope auto-set) + - --scope=draft -> draft + - --scope=locked --version=N -> locked, version N + +Example: + dr pipeline input list --pipeline + dr pipeline input list --pipeline --version=2 + dr pipeline input list --pipeline --offset 50 --limit 10 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + items, err := pipeline.ListInputs(flags.PipelineID, scope, version, offset, limit) + if err != nil { + return err + } + + return pipeline.RenderInputs(outputFormat, items) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of inputs to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "offset": offset, + "limit": limit, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/input/list/cmd_test.go b/cmd/pipeline/input/list/cmd_test.go new file mode 100644 index 000000000..c39ace176 --- /dev/null +++ b/cmd/pipeline/input/list/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t) + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/input/update/cmd.go b/cmd/pipeline/input/update/cmd.go new file mode 100644 index 000000000..1351f7836 --- /dev/null +++ b/cmd/pipeline/input/update/cmd.go @@ -0,0 +1,77 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + fromFile string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "update []", + Short: "Update a draft pipeline input set", + Long: `Update the payload of a draft input set. + +Locked inputs are immutable; the API will return 409 if you try to update +one. The new payload must be a JSON object supplied either as a positional +argument or via --from-file=. + +Example: + dr pipeline input update --pipeline ./new_payload.json + dr pipeline input update --pipeline --from-file=./new_payload.json --output-format json`, + Args: cobra.RangeArgs(1, 2), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + inputID := args[0] + + payload, err := pipeline.ResolvePayload(args[1:], fromFile) + if err != nil { + return err + } + + result, err := pipeline.UpdateInput(pipelineID, inputID, payload) + if err != nil { + return err + } + + return pipeline.RenderInput(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "input_id": telemetry.FirstArg(args), + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/input/update/cmd_test.go b/cmd/pipeline/input/update/cmd_test.go new file mode 100644 index 000000000..8e7bd2584 --- /dev/null +++ b/cmd/pipeline/input/update/cmd_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "in-1", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "in-1", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RequiresInputID(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_RejectsMissingPayload(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "from-file", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/schedule/cmd.go b/cmd/pipeline/schedule/cmd.go new file mode 100644 index 000000000..78308c4d3 --- /dev/null +++ b/cmd/pipeline/schedule/cmd.go @@ -0,0 +1,46 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package schedule + +import ( + "github.com/datarobot/cli/cmd/pipeline/schedule/create" + "github.com/datarobot/cli/cmd/pipeline/schedule/del" + "github.com/datarobot/cli/cmd/pipeline/schedule/get" + "github.com/datarobot/cli/cmd/pipeline/schedule/list" + "github.com/datarobot/cli/cmd/pipeline/schedule/update" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline schedule`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "schedule", + Short: "Manage pipeline schedules", + Long: `Manage recurring (cron) runs of locked pipeline versions. + +Schedules are only valid for locked pipeline versions, so every verb +requires --pipeline and --version.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + update.Cmd(), + del.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/schedule/cmd_test.go b/cmd/pipeline/schedule/cmd_test.go new file mode 100644 index 000000000..5898ff7dd --- /dev/null +++ b/cmd/pipeline/schedule/cmd_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package schedule + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "update": false, + "delete": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipeline/schedule/create/cmd.go b/cmd/pipeline/schedule/create/cmd.go new file mode 100644 index 000000000..9d190c4da --- /dev/null +++ b/cmd/pipeline/schedule/create/cmd.go @@ -0,0 +1,87 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "errors" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + cron string + inputID string + timezone string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a recurring schedule for a locked pipeline version", + Long: `Register a cron-style schedule that triggers a run on a fixed cadence. + +Example: + dr pipeline schedule create --pipeline --version=2 --cron "0 * * * *" --input + dr pipeline schedule create --pipeline --version=2 --cron "0 9 * * *" --input --timezone America/Los_Angeles`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + body := pipeline.ScheduleCreateRequest{ + CronExpression: cron, + PipelineInputID: inputID, + Timezone: timezone, + } + + result, err := pipeline.CreateSchedule(pipelineID, version, body) + if err != nil { + return err + } + + return pipeline.RenderSchedule(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") + cmd.Flags().StringVar(&cron, "cron", "", "Cron expression, e.g. \"0 * * * *\"") + _ = cmd.MarkFlagRequired("cron") + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to run on each tick") + _ = cmd.MarkFlagRequired("input") + cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "version": version, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/schedule/create/cmd_test.go b/cmd/pipeline/schedule/create/cmd_test.go new file mode 100644 index 000000000..6eba56a4e --- /dev/null +++ b/cmd/pipeline/schedule/create/cmd_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, + "--pipeline", "p", "--version", "2", + "--cron", "0 * * * *", "--input", "in-1", + "--output-format", "yaml", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--version", "2", "--cron", "0 * * * *", "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsMissingVersion(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--cron", "0 * * * *", "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "version") +} + +func TestCmd_RejectsMissingCron(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2", "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "cron") +} + +func TestCmd_RejectsMissingInput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2", "--cron", "0 * * * *") + require.Error(t, err) + assert.Contains(t, err.Error(), "input") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "version", "cron", "input", "timezone", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/schedule/del/cmd.go b/cmd/pipeline/schedule/del/cmd.go new file mode 100644 index 000000000..77cd034b7 --- /dev/null +++ b/cmd/pipeline/schedule/del/cmd.go @@ -0,0 +1,95 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package del implements the `dr pipeline schedule delete` verb. The +// directory is named `del` rather than `delete` because the latter +// shadows Go's built-in delete() function in importing files. + +package del + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline schedule", + Long: `Delete a recurring schedule from a locked pipeline version. + +Example: + dr pipeline schedule delete --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + err := pipeline.DeleteSchedule(pipelineID, version, args[0]) + if err != nil { + return handleDeleteError(err, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted schedule: " + args[0])) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "schedule_id": telemetry.FirstArg(args), + "version": version, + } + }) + + return cmd +} + +// handleDeleteError converts a 404 into a friendly informational message +// (returns nil) so the user does not see a stack-trace-style HTTP error +// for what is effectively a no-op. +func handleDeleteError(err error, id string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No schedule found with id: " + id)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/schedule/del/cmd_test.go b/cmd/pipeline/schedule/del/cmd_test.go new file mode 100644 index 000000000..924933a7c --- /dev/null +++ b/cmd/pipeline/schedule/del/cmd_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--version", "2", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsMissingVersion(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "version") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "delete", Cmd().Name()) +} diff --git a/cmd/pipeline/schedule/get/cmd.go b/cmd/pipeline/schedule/get/cmd.go new file mode 100644 index 000000000..764cf40e3 --- /dev/null +++ b/cmd/pipeline/schedule/get/cmd.go @@ -0,0 +1,90 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline schedule", + Long: `Display the cron expression, timezone, and lifecycle status of a schedule. + +Example: + dr pipeline schedule get --pipeline --version=2 + dr pipeline schedule get --pipeline --version=2 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + result, err := pipeline.GetSchedule(pipelineID, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + return pipeline.RenderSchedule(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "schedule_id": telemetry.FirstArg(args), + "version": version, + "output_format": string(outputFormat), + } + }) + + return cmd +} + +func handleGetError(err error, scheduleID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No schedule found with id: " + scheduleID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/schedule/get/cmd_test.go b/cmd/pipeline/schedule/get/cmd_test.go new file mode 100644 index 000000000..409c70fbd --- /dev/null +++ b/cmd/pipeline/schedule/get/cmd_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2", "--output-format", "yaml", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--version", "2", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsMissingVersion(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "version") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2") + require.Error(t, err) +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleGetError(httpErr, "s-1")) +} + +func TestHandleGetError_PropagatesOther(t *testing.T) { + err := handleGetError(errors.New("boom"), "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipeline/schedule/list/cmd.go b/cmd/pipeline/schedule/list/cmd.go new file mode 100644 index 000000000..b9c647d87 --- /dev/null +++ b/cmd/pipeline/schedule/list/cmd.go @@ -0,0 +1,79 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "errors" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List schedules for a locked pipeline version", + Long: `List recurring schedules attached to a locked pipeline version. + +Example: + dr pipeline schedule list --pipeline --version=2 + dr pipeline schedule list --pipeline --version=2 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + items, err := pipeline.ListSchedules(pipelineID, version, offset, limit) + if err != nil { + return err + } + + return pipeline.RenderSchedules(outputFormat, items) + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of schedules to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "version": version, + "offset": offset, + "limit": limit, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/schedule/list/cmd_test.go b/cmd/pipeline/schedule/list/cmd_test.go new file mode 100644 index 000000000..460b9fcb2 --- /dev/null +++ b/cmd/pipeline/schedule/list/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--version", "2") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsMissingVersion(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "version") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "version", "offset", "limit", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go new file mode 100644 index 000000000..41e56ed43 --- /dev/null +++ b/cmd/pipeline/schedule/update/cmd.go @@ -0,0 +1,119 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "errors" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + cron string + timezone string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a pipeline schedule", + Long: `Update the cron expression and/or timezone of an existing schedule. + +At least one of --cron or --timezone must be supplied; otherwise the +command sends an empty patch which the API treats as a no-op. + +Example: + dr pipeline schedule update --pipeline --version=2 --cron "*/15 * * * *" + dr pipeline schedule update --pipeline --version=2 --timezone Europe/Berlin`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + body, err := buildUpdateBody(cmd, pipelineID, version, cron, timezone) + if err != nil { + return err + } + + result, err := pipeline.UpdateSchedule(pipelineID, version, args[0], body) + if err != nil { + return err + } + + return pipeline.RenderSchedule(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") + cmd.Flags().StringVar(&cron, "cron", "", "New cron expression") + cmd.Flags().StringVar(&timezone, "timezone", "", "New IANA timezone name") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "schedule_id": telemetry.FirstArg(args), + "version": version, + "output_format": string(outputFormat), + } + }) + + return cmd +} + +// buildUpdateBody validates the flag set and assembles the PATCH body. It is +// extracted from RunE to keep the cobra command's cyclomatic complexity low. +func buildUpdateBody(cmd *cobra.Command, _ string, version int, cron, timezone string) (pipeline.ScheduleUpdateRequest, error) { + if version <= 0 { + return pipeline.ScheduleUpdateRequest{}, errors.New("--version is required and must be > 0") + } + + cronChanged := cmd.Flags().Changed("cron") + tzChanged := cmd.Flags().Changed("timezone") + + if !cronChanged && !tzChanged { + return pipeline.ScheduleUpdateRequest{}, errors.New("at least one of --cron or --timezone must be specified") + } + + if cronChanged && cron == "" { + return pipeline.ScheduleUpdateRequest{}, errors.New("--cron must not be empty") + } + + if tzChanged && timezone == "" { + return pipeline.ScheduleUpdateRequest{}, errors.New("--timezone must not be empty") + } + + body := pipeline.ScheduleUpdateRequest{} + + if cronChanged { + v := cron + body.CronExpression = &v + } + + if tzChanged { + v := timezone + body.Timezone = &v + } + + return body, nil +} diff --git a/cmd/pipeline/schedule/update/cmd_test.go b/cmd/pipeline/schedule/update/cmd_test.go new file mode 100644 index 000000000..d73ba6656 --- /dev/null +++ b/cmd/pipeline/schedule/update/cmd_test.go @@ -0,0 +1,120 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildUpdateBody_RejectsEmptyCron(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--version=2", "--cron="})) + + _, err := buildUpdateBody(cmd, "p", 2, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--cron must not be empty") +} + +func TestBuildUpdateBody_RejectsEmptyTimezone(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--version=2", "--timezone="})) + + _, err := buildUpdateBody(cmd, "p", 2, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--timezone must not be empty") +} + +func TestBuildUpdateBody_RequiresAtLeastOneField(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--version=2"})) + + _, err := buildUpdateBody(cmd, "p", 2, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one of --cron") +} + +func TestBuildUpdateBody_PicksUpChangedFlags(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{ + "--pipeline=p", "--version=2", + "--cron=*/5 * * * *", + "--timezone=America/Los_Angeles", + })) + + body, err := buildUpdateBody(cmd, "p", 2, "*/5 * * * *", "America/Los_Angeles") + require.NoError(t, err) + require.NotNil(t, body.CronExpression) + require.NotNil(t, body.Timezone) + assert.Equal(t, "*/5 * * * *", *body.CronExpression) + assert.Equal(t, "America/Los_Angeles", *body.Timezone) +} + +func TestBuildUpdateBody_SkipsUnchangedFlags(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + // only --cron supplied; --timezone untouched + require.NoError(t, cmd.ParseFlags([]string{ + "--pipeline=p", "--version=2", + "--cron=0 0 * * *", + })) + + body, err := buildUpdateBody(cmd, "p", 2, "0 0 * * *", "") + require.NoError(t, err) + require.NotNil(t, body.CronExpression) + assert.Equal(t, "0 0 * * *", *body.CronExpression) + assert.Nil(t, body.Timezone, "untouched --timezone should not be sent") +} + +func TestBuildUpdateBody_RejectsZeroVersion(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--cron=0 0 * * *"})) + + _, err := buildUpdateBody(cmd, "p", 0, "0 0 * * *", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--version") +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"sched-id", "--pipeline=p", "--version=2", "--cron=0 0 * * *", "--output-format=yaml"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} diff --git a/docs/commands/README.md b/docs/commands/README.md index 4a4e0692a..5ecbcf45e 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -86,12 +86,24 @@ dr │ │ ├── list List versions of a pipeline │ │ └── get Display details of a single pipeline version │ ├── graph Display the pipeline/task DAG of a pipeline -│ └── run Trigger and inspect pipeline executions -│ ├── create Trigger a run from an input -│ ├── list List runs for a pipeline -│ ├── get Display a single run -│ ├── status Lightweight run status (for polling) -│ └── cancel Cancel a running run +│ ├── run Trigger and inspect pipeline executions +│ │ ├── create Trigger a run from an input +│ │ ├── list List runs for a pipeline +│ │ ├── get Display a single run +│ │ ├── status Lightweight run status (for polling) +│ │ └── cancel Cancel a running run +│ ├── input Manage pipeline input payloads +│ │ ├── create Register a JSON payload on a pipeline +│ │ ├── list List inputs for a pipeline (draft or locked scope) +│ │ ├── get Display a single input +│ │ ├── update Update a draft input's payload +│ │ └── delete Delete an input +│ └── schedule Manage recurring (cron) runs (locked-only) +│ ├── create Register a recurring schedule on a locked version +│ ├── list List schedules for a locked version +│ ├── get Display a single schedule +│ ├── update Change cron expression / timezone +│ └── delete Delete a schedule └── self CLI utility commands ├── completion Shell completion │ ├── install Install completions interactively @@ -263,6 +275,8 @@ For detailed documentation on each command, see: - `version`—`list` / `get` to inspect pipeline versions. - `graph`—display the pipeline/task DAG (draft or locked). - `run`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. + - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by runs. + - `schedule`—`create`/`list`/`get`/`update`/`delete` recurring (cron) runs on locked versions. ## Getting help diff --git a/docs/commands/pipeline.md b/docs/commands/pipeline.md index 226ac32a4..e90d03a75 100644 --- a/docs/commands/pipeline.md +++ b/docs/commands/pipeline.md @@ -79,6 +79,8 @@ dr pipeline lock | `dr pipeline version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | | `dr pipeline graph` | `…/graph` (draft or locked) | Render the pipeline/task DAG. | | `dr pipeline run …` | `…/dispatches` and `…/{id}` | Trigger, inspect, and cancel runs. | +| `dr pipeline input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for runs. | +| `dr pipeline schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) runs on locked versions. | ## Subcommands @@ -301,6 +303,36 @@ dr pipeline version get --pipeline 2 dr pipeline graph --pipeline --version=2 --output-format json ``` +### `input` + +Manage JSON payloads that drive a run. + +```bash +dr pipeline input create --pipeline # draft scope +dr pipeline input create --pipeline --version=N # locked scope +dr pipeline input list --pipeline [--scope|--version] [--offset N] [--limit N] +dr pipeline input get --pipeline [--scope|--version] +dr pipeline input update --pipeline # draft only +dr pipeline input delete --pipeline [--scope|--version] +``` + +The payload file must contain a JSON object. The CLI wraps it in `{"payload": …}` before sending. + +### `schedule` + +Manage recurring (cron) runs on locked versions only. Both `--pipeline` and `--version` are +required for every verb. + +```bash +dr pipeline schedule create --pipeline --version=N \ + --cron "0 * * * *" --input [--timezone UTC] +dr pipeline schedule list --pipeline --version=N [--offset N] [--limit N] +dr pipeline schedule get --pipeline --version=N +dr pipeline schedule update --pipeline --version=N --cron "*/15 * * * *" +dr pipeline schedule delete --pipeline --version=N +``` + +`schedule update` requires at least one of `--cron` or `--timezone`. ### `run` Trigger, inspect, and cancel pipeline executions. diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index b4a37394a..84789720d 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -45,7 +45,7 @@ accepts. ## Shared flag semantics -### `--scope` / `--version` (graph) +### `--scope` / `--version` (inputs, runs, graph) The CLI mirrors the API's two URL shapes — `/pipelines/{id}/…` for the mutable draft and `/pipelines/{id}/versions/{ver}/…` for a locked @@ -86,6 +86,21 @@ exercising a local API stub that doesn't implement `/version/`. --- +## Inputs (`dr pipeline input …`) + +Inputs exist in two scopes — **draft** and **locked** — selected via `--scope` / `--version`. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline input create` | `POST /pipelines/{id}/inputs` (draft)
`POST /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipeline input create --pipeline ./payload.json`
`dr pipeline input create --pipeline --version=2 ./payload.json --output-format json` | **Positional:** `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--from-file=`, `--output-format json`. | +| `dr pipeline input list` | `GET /pipelines/{id}/inputs` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipeline input list --pipeline ` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output-format json`. | +| `dr pipeline input get` | `GET /pipelines/{id}/inputs/{input_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipeline input get --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipeline input update` | `PATCH /pipelines/{id}/inputs/{input_id}` (draft only) | `dr pipeline input update --pipeline ./payload.json` | **Positional:** `` (required), ``. **Flags:** `--pipeline ` (required), `--from-file=`, `--output-format json`. | +| `dr pipeline input delete` | `DELETE /pipelines/{id}/inputs/{input_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipeline input delete --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + + ## Runs (`dr pipeline run …`) Same draft/locked scope rules as graph. The wire-level URLs still use the legacy @@ -102,6 +117,20 @@ term `dispatches` / `dispatch_id`, but the CLI's `--output-format json` remaps t --- +## Schedules (`dr pipeline schedule …`) + +Schedules are **locked-only** — every verb requires both `--pipeline` and `--version`. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline schedule create` | `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule create --pipeline --version=2 --cron "0 * * * *" --input ` | **Flags:** `--pipeline ` (required), `--version ` (required), `--cron ""` (required), `--input ` (required), `--timezone ` (default `UTC`), `--output-format json`. | +| `dr pipeline schedule list` | `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule list --pipeline --version=2` | **Flags:** `--pipeline ` (required), `--version ` (required), `--offset `, `--limit `, `--output-format json`. | +| `dr pipeline schedule get` | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule get --pipeline --version=2 ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--version ` (required), `--output-format json`. | +| `dr pipeline schedule update` | `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule update --pipeline --version=2 --cron "*/15 * * * *"` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--version ` (required), `--cron ""`, `--timezone `. At least one required. | +| `dr pipeline schedule delete` | `DELETE /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule delete --pipeline --version=2 ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--version ` (required). | + +--- + ## Quick endpoint lookup | API endpoint | CLI command | @@ -120,3 +149,17 @@ term `dispatches` / `dispatch_id`, but the CLI's `--output-format json` remaps t | `GET /pipelines/{id}/dispatches` | `dr pipeline run list` (draft) | | `GET /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run get` (draft) | | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run cancel` (draft) | +| `POST /pipelines/{id}/inputs` | `dr pipeline input create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input create` (locked) | +| `GET /pipelines/{id}/inputs` | `dr pipeline input list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input list` (locked) | +| `GET /pipelines/{id}/inputs/{input_id}` | `dr pipeline input get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input get` (locked) | +| `PATCH /pipelines/{id}/inputs/{input_id}` | `dr pipeline input update` (draft) | +| `DELETE /pipelines/{id}/inputs/{input_id}` | `dr pipeline input delete` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input delete` (locked) | +| `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule create` | +| `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule list` | +| `GET /pipelines/{id}/versions/{ver}/schedules/{id}` | `dr pipeline schedule get` | +| `PATCH /pipelines/{id}/versions/{ver}/schedules/{id}` | `dr pipeline schedule update` | +| `DELETE /pipelines/{id}/versions/{ver}/schedules/{id}` | `dr pipeline schedule delete` | diff --git a/internal/pipeline/input.go b/internal/pipeline/input.go new file mode 100644 index 000000000..f01772ad5 --- /dev/null +++ b/internal/pipeline/input.go @@ -0,0 +1,153 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// input.go contains the typed client wrappers for the pipeline input +// endpoints described in pipelines-api/.../controllers/pipeline_input.py. +// Both draft and locked URL shapes are exercised through Scope/version. + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" + "time" +) + +// InputState mirrors PipelineInputState in the pipelines-api enums. +type InputState string + +const ( + InputStateValid InputState = "VALID" + InputStateInvalid InputState = "INVALID" +) + +// Input mirrors PipelineInputResponse from the pipelines-api. +type Input struct { + InputID string `json:"id"` + PipelineID string `json:"pipelineId"` + VersionID *int `json:"versionId,omitempty"` + IsDraft bool `json:"isDraft"` + Payload map[string]any `json:"payload"` + State InputState `json:"state"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// InputCreateRequest mirrors PipelineInputCreateRequest. +type InputCreateRequest struct { + Payload map[string]any `json:"payload"` +} + +// InputUpdateRequest mirrors PipelineInputUpdateRequest (draft-only). +type InputUpdateRequest struct { + Payload map[string]any `json:"payload"` +} + +// CreateInput POSTs a new input set against the appropriate URL for the +// given scope/version. +func CreateInput(pipelineID string, scope Scope, version *int, payload map[string]any) (*Input, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs") + if err != nil { + return nil, err + } + + body := InputCreateRequest{Payload: payload} + + var result Input + + err = doJSON(http.MethodPost, endpoint, body, "create input", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListInputs returns a paginated slice of inputs for the given scope. +func ListInputs(pipelineID string, scope Scope, version *int, offset, limit int) ([]Input, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var page DataPage[Input] + + err = doJSON(http.MethodGet, endpoint, nil, "inputs", &page) + if err != nil { + return nil, err + } + + return page.Data, nil +} + +// GetInput fetches a single input by id within the given scope. +func GetInput(pipelineID string, scope Scope, version *int, inputID string) (*Input, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs/"+inputID) + if err != nil { + return nil, err + } + + var input Input + + err = doJSON(http.MethodGet, endpoint, nil, "input", &input) + if err != nil { + return nil, err + } + + return &input, nil +} + +// UpdateInput PATCHes a draft input set with a new payload. Locked inputs +// cannot be updated; the API will return 409 in that case. +func UpdateInput(pipelineID, inputID string, payload map[string]any) (*Input, error) { + endpoint, err := EndpointFor(pipelineID, ScopeDraft, nil, "inputs/"+inputID) + if err != nil { + return nil, err + } + + body := InputUpdateRequest{Payload: payload} + + var result Input + + err = doJSON(http.MethodPatch, endpoint, body, "update input", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteInput removes an input set within the given scope. +func DeleteInput(pipelineID string, scope Scope, version *int, inputID string) error { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs/"+inputID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete input") +} diff --git a/internal/pipeline/input_output.go b/internal/pipeline/input_output.go new file mode 100644 index 000000000..6d8f9158b --- /dev/null +++ b/internal/pipeline/input_output.go @@ -0,0 +1,193 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// input_output.go centralises the human/JSON output rendering used by the +// input verbs so each verb file stays focused on flag wiring. +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strconv" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// inputJSON is the CLI-facing DTO used for `--output-format json`. +type inputJSON struct { + InputID string `json:"input_id"` + PipelineID string `json:"pipeline_id"` + Scope string `json:"scope"` + VersionID *int `json:"version_id,omitempty"` + State string `json:"state"` + Payload map[string]any `json:"payload"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toInputJSON(input Input) inputJSON { + scope := "draft" + + if input.VersionID != nil { + scope = "locked" + } + + return inputJSON{ + InputID: input.InputID, + PipelineID: input.PipelineID, + Scope: scope, + VersionID: input.VersionID, + State: string(input.State), + Payload: input.Payload, + CreatedAt: input.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: input.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderInput routes a single input to JSON or human output. +func RenderInput(format OutputFormat, input Input) error { + if format == OutputFormatJSON { + return PrintInputJSON(input) + } + + PrintInputHuman(input) + + return nil +} + +// RenderInputs routes a list of inputs to JSON or human output. +func RenderInputs(format OutputFormat, inputs []Input) error { + if format == OutputFormatJSON { + return PrintInputListJSON(inputs) + } + + PrintInputListHuman(inputs) + + return nil +} + +// PrintInputJSON marshals an input record as indented JSON through the DTO. +func PrintInputJSON(input Input) error { + data, err := json.MarshalIndent(toInputJSON(input), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintInputHuman renders the key facts about a single input record. +func PrintInputHuman(input Input) { + scope := "draft" + versionDisplay := emptyValuePlaceholder + + if input.VersionID != nil { + scope = "locked" + versionDisplay = strconv.Itoa(*input.VersionID) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Input ID:\t%s\n", input.InputID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", input.PipelineID) + fmt.Fprintf(w, "Scope:\t%s\n", scope) + fmt.Fprintf(w, "Version:\t%s\n", versionDisplay) + fmt.Fprintf(w, "State:\t%s\n", string(input.State)) + fmt.Fprintf(w, "Created:\t%s\n", input.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", input.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() + + payload, err := json.MarshalIndent(input.Payload, "", " ") + if err != nil { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Payload:")) + fmt.Println(string(payload)) +} + +// PrintInputListJSON marshals a list of inputs as indented JSON through the DTO. +func PrintInputListJSON(inputs []Input) error { + view := make([]inputJSON, len(inputs)) + + for i, in := range inputs { + view[i] = toInputJSON(in) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintInputListHuman renders a lipgloss table summary of inputs. +func PrintInputListHuman(inputs []Input) { + if len(inputs) == 0 { + fmt.Println(tui.DimStyle.Render("No inputs found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"INPUT ID", "SCOPE", "VERSION", "STATE", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, in := range inputs { + scope := "draft" + ver := emptyValuePlaceholder + + if in.VersionID != nil { + scope = "locked" + ver = strconv.Itoa(*in.VersionID) + } + + t.Row(in.InputID, scope, ver, string(in.State), in.UpdatedAt.UTC().Format(timestampFormat)) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} diff --git a/internal/pipeline/input_output_test.go b/internal/pipeline/input_output_test.go new file mode 100644 index 000000000..c92174541 --- /dev/null +++ b/internal/pipeline/input_output_test.go @@ -0,0 +1,141 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleInput() Input { + ver := 3 + + return Input{ + InputID: "in-1", + PipelineID: "p-1", + VersionID: &ver, + IsDraft: false, + Payload: map[string]any{"key": "value"}, + State: InputStateValid, + CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 29, 10, 5, 0, 0, time.UTC), + } +} + +// ── toInputJSON remapping ──────────────────────────────────────────────────── + +func TestToInputJSON_RemapsWireFields(t *testing.T) { + j := toInputJSON(sampleInput()) + + assert.Equal(t, "in-1", j.InputID) + assert.Equal(t, "p-1", j.PipelineID) + assert.Equal(t, "locked", j.Scope) + require.NotNil(t, j.VersionID) + assert.Equal(t, 3, *j.VersionID) + assert.Equal(t, string(InputStateValid), j.State) + assert.Equal(t, map[string]any{"key": "value"}, j.Payload) +} + +func TestToInputJSON_DraftScope(t *testing.T) { + in := sampleInput() + in.VersionID = nil + + j := toInputJSON(in) + + assert.Equal(t, "draft", j.Scope) + assert.Nil(t, j.VersionID) +} + +func TestToInputJSON_FormatsTimestampsAsRFC3339(t *testing.T) { + j := toInputJSON(sampleInput()) + + assert.Equal(t, "2026-04-29T10:00:00Z", j.CreatedAt) + assert.Equal(t, "2026-04-29T10:05:00Z", j.UpdatedAt) +} + +func TestToInputJSON_JSONKeysUseCliVocabulary(t *testing.T) { + data, err := json.Marshal(toInputJSON(sampleInput())) + require.NoError(t, err) + + var raw map[string]any + + require.NoError(t, json.Unmarshal(data, &raw)) + + assert.Contains(t, raw, "input_id", "wire 'id' must be remapped to 'input_id'") + assert.Contains(t, raw, "pipeline_id") + assert.Contains(t, raw, "scope") + assert.Contains(t, raw, "version_id") + assert.NotContains(t, raw, "id", "raw wire key 'id' must not appear in CLI output") + assert.NotContains(t, raw, "pipelineId") + assert.NotContains(t, raw, "versionId") +} + +// ── RenderInput ────────────────────────────────────────────────────────────── + +func TestRenderInput_JSON(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, RenderInput(OutputFormatJSON, sampleInput())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + assert.Equal(t, "in-1", parsed["input_id"]) + assert.Equal(t, "locked", parsed["scope"]) + assert.Equal(t, "2026-04-29T10:00:00Z", parsed["created_at"]) +} + +func TestRenderInput_Human(t *testing.T) { + out := captureStdout(t, func() { PrintInputHuman(sampleInput()) }) + + assert.Contains(t, out, "in-1") + assert.Contains(t, out, "locked") + assert.Contains(t, out, string(InputStateValid)) +} + +// ── PrintInputListJSON ─────────────────────────────────────────────────────── + +func TestPrintInputListJSON_RemapsFields(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, PrintInputListJSON([]Input{sampleInput()})) + }) + + var parsed []map[string]any + + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + require.Len(t, parsed, 1) + assert.Equal(t, "in-1", parsed[0]["input_id"]) + assert.Equal(t, "locked", parsed[0]["scope"]) +} + +// ── PrintInputListHuman ────────────────────────────────────────────────────── + +func TestPrintInputListHuman_Empty(t *testing.T) { + out := captureStdout(t, func() { PrintInputListHuman(nil) }) + assert.Contains(t, out, "No inputs found") +} + +func TestPrintInputListHuman_RendersTable(t *testing.T) { + out := captureStdout(t, func() { PrintInputListHuman([]Input{sampleInput()}) }) + + assert.Contains(t, out, "INPUT ID") + assert.Contains(t, out, "in-1") + assert.Contains(t, out, "locked") + assert.Contains(t, out, string(InputStateValid)) +} diff --git a/internal/pipeline/input_payload.go b/internal/pipeline/input_payload.go new file mode 100644 index 000000000..dc92530d5 --- /dev/null +++ b/internal/pipeline/input_payload.go @@ -0,0 +1,71 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// input_payload.go contains the shared helper for resolving an input payload +// from either a positional argument or the --from-file flag, then parsing +// it as JSON. +package pipeline + +import ( + "encoding/json" + "errors" + "fmt" + "os" +) + +// ResolvePayload mirrors the create/update flag pattern from +// `dr pipeline create`: a JSON file path can be supplied either as a +// positional argument or via --from-file=; exactly one of the two +// must be provided. The contents of the file must be a JSON object so it +// fits the `{payload: object}` body the API expects. +func ResolvePayload(args []string, fromFile string) (map[string]any, error) { + path, err := resolvePayloadFilePath(args, fromFile) + if err != nil { + return nil, err + } + + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var payload map[string]any + + err = json.Unmarshal(raw, &payload) + if err != nil { + return nil, fmt.Errorf("parse %s as JSON object: %w", path, err) + } + + return payload, nil +} + +// resolvePayloadFilePath returns the file path supplied either positionally or +// via --from-file. Exactly one of the two must be provided. +func resolvePayloadFilePath(args []string, fromFile string) (string, error) { + positional := "" + if len(args) > 0 { + positional = args[0] + } + + switch { + case positional != "" && fromFile != "": + return "", errors.New("specify the file either as a positional argument or via --from-file, not both") + case positional != "": + return positional, nil + case fromFile != "": + return fromFile, nil + default: + return "", errors.New("a JSON payload file is required (positional argument or --from-file)") + } +} diff --git a/internal/pipeline/input_payload_test.go b/internal/pipeline/input_payload_test.go new file mode 100644 index 000000000..a1f594826 --- /dev/null +++ b/internal/pipeline/input_payload_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTemp(t *testing.T, content string) string { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "payload-*.json") + + require.NoError(t, err) + + _, err = f.WriteString(content) + + require.NoError(t, err) + require.NoError(t, f.Close()) + + return f.Name() +} + +// ── resolvePayloadFilePath ─────────────────────────────────────────────────── + +func TestResolvePayloadFilePath_Positional(t *testing.T) { + path, err := resolvePayloadFilePath([]string{"/some/file.json"}, "") + + require.NoError(t, err) + assert.Equal(t, "/some/file.json", path) +} + +func TestResolvePayloadFilePath_FromFile(t *testing.T) { + path, err := resolvePayloadFilePath(nil, "/some/file.json") + + require.NoError(t, err) + assert.Equal(t, "/some/file.json", path) +} + +func TestResolvePayloadFilePath_BothProvided(t *testing.T) { + _, err := resolvePayloadFilePath([]string{"/a.json"}, "/b.json") + + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +func TestResolvePayloadFilePath_NeitherProvided(t *testing.T) { + _, err := resolvePayloadFilePath(nil, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +// ── ResolvePayload ─────────────────────────────────────────────────────────── + +func TestResolvePayload_PositionalArg(t *testing.T) { + path := writeTemp(t, `{"key":"value"}`) + + got, err := ResolvePayload([]string{path}, "") + + require.NoError(t, err) + assert.Equal(t, map[string]any{"key": "value"}, got) +} + +func TestResolvePayload_FromFile(t *testing.T) { + path := writeTemp(t, `{"count":42}`) + + got, err := ResolvePayload(nil, path) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"count": float64(42)}, got) +} + +func TestResolvePayload_BothProvided(t *testing.T) { + path := writeTemp(t, `{}`) + + _, err := ResolvePayload([]string{path}, path) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +func TestResolvePayload_NeitherProvided(t *testing.T) { + _, err := ResolvePayload(nil, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestResolvePayload_FileNotFound(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist.json") + + _, err := ResolvePayload([]string{missing}, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "read") +} + +func TestResolvePayload_InvalidJSON(t *testing.T) { + path := writeTemp(t, `not json`) + + _, err := ResolvePayload([]string{path}, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "parse") + assert.Contains(t, err.Error(), "JSON object") +} + +func TestResolvePayload_NonObjectJSON(t *testing.T) { + path := writeTemp(t, `[1, 2, 3]`) + + _, err := ResolvePayload([]string{path}, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "parse") + assert.Contains(t, err.Error(), "JSON object") +} diff --git a/internal/pipeline/input_test.go b/internal/pipeline/input_test.go new file mode 100644 index 000000000..b96574ea4 --- /dev/null +++ b/internal/pipeline/input_test.go @@ -0,0 +1,209 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// installEndpoint sets viper's endpoint to the given URL for the duration +// of the test, restoring the previous value at cleanup. +func installEndpoint(t *testing.T, url string) { + t.Helper() + + prev := viperx.GetString(config.DataRobotURL) + + viperx.Set(config.DataRobotURL, url) + + t.Cleanup(func() { + viperx.Set(config.DataRobotURL, prev) + }) +} + +func TestCreateInput_Draft(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/inputs", r.URL.Path) + + var body InputCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "v", body.Payload["k"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{"k":"v"}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateInput("p-1", ScopeDraft, nil, map[string]any{"k": "v"}) + require.NoError(t, err) + assert.Equal(t, "in-1", got.InputID) + assert.Equal(t, InputStateValid, got.State) +} + +func TestCreateInput_LockedURLShape(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/inputs", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","versionId":2,"isDraft":false,"state":"VALID","payload":{}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := CreateInput("p-1", ScopeLocked, &v, map[string]any{}) + require.NoError(t, err) + require.NotNil(t, got.VersionID) + assert.Equal(t, 2, *got.VersionID) +} + +func TestListInputs_AddsPaginationQuery(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/inputs", r.URL.Path) + assert.Equal(t, "10", r.URL.Query().Get("offset")) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{}}],"totalCount":1,"count":1}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListInputs("p-1", ScopeDraft, nil, 10, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "in-1", items[0].InputID) +} + +func TestListInputs_OmitsZeroPagination(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.URL.RawQuery) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[],"totalCount":0,"count":0}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListInputs("p-1", ScopeDraft, nil, 0, 0) + require.NoError(t, err) + assert.Empty(t, items) +} + +func TestGetInput_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/inputs/in-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetInput("p-1", ScopeDraft, nil, "in-1") + require.NoError(t, err) + assert.Equal(t, "in-1", got.InputID) +} + +func TestUpdateInput_PatchesDraft(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/inputs/in-1", r.URL.Path) + + var body InputUpdateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "new", body.Payload["k"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{"k":"new"}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := UpdateInput("p-1", "in-1", map[string]any{"k": "new"}) + require.NoError(t, err) + assert.Equal(t, "new", got.Payload["k"]) +} + +func TestDeleteInput_LockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/3/inputs/in-1", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 3 + require.NoError(t, DeleteInput("p-1", ScopeLocked, &v, "in-1")) +} + +func TestDeleteInput_PropagatesAPIError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"locked input"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := DeleteInput("p-1", ScopeDraft, nil, "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") +} diff --git a/internal/pipeline/schedule.go b/internal/pipeline/schedule.go new file mode 100644 index 000000000..c63aab31a --- /dev/null +++ b/internal/pipeline/schedule.go @@ -0,0 +1,153 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// schedule.go wraps the pipeline schedule endpoints described in +// pipelines-api/.../controllers/pipeline_schedule.py. Schedules are only +// valid for locked pipeline versions, so the URL always carries a +// /versions/{ver} segment and there is no Scope parameter on this side. + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" + "time" +) + +// ScheduleStatus mirrors PipelineScheduleStatus in the pipelines-api enums. +type ScheduleStatus string + +const ( + ScheduleStatusActive ScheduleStatus = "ACTIVE" + ScheduleStatusPaused ScheduleStatus = "PAUSED" + ScheduleStatusDeleted ScheduleStatus = "DELETED" +) + +// Schedule mirrors PipelineScheduleResponse. +type Schedule struct { + ScheduleID string `json:"id"` + PipelineID string `json:"pipelineId"` + Version int `json:"version"` + CronExpression string `json:"cronExpression"` + Timezone string `json:"timezone"` + Status ScheduleStatus `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ScheduleCreateRequest mirrors PipelineScheduleCreateRequest. +type ScheduleCreateRequest struct { + CronExpression string `json:"cron_expression"` + PipelineInputID string `json:"pipeline_input_id"` + Timezone string `json:"timezone,omitempty"` +} + +// ScheduleUpdateRequest mirrors PipelineScheduleUpdateRequest. Both fields +// are optional; the API treats omitted values as no-op. +type ScheduleUpdateRequest struct { + CronExpression *string `json:"cron_expression,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +// CreateSchedule registers a new recurring run for a locked version. +func CreateSchedule(pipelineID string, version int, body ScheduleCreateRequest) (*Schedule, error) { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules") + if err != nil { + return nil, err + } + + var result Schedule + + err = doJSON(http.MethodPost, endpoint, body, "create schedule", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListSchedules returns a paginated list of schedules for a locked version. +func ListSchedules(pipelineID string, version, offset, limit int) ([]Schedule, error) { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var page DataPage[Schedule] + + err = doJSON(http.MethodGet, endpoint, nil, "schedules", &page) + if err != nil { + return nil, err + } + + return page.Data, nil +} + +// GetSchedule fetches a single schedule by id. +func GetSchedule(pipelineID string, version int, scheduleID string) (*Schedule, error) { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules/"+scheduleID) + if err != nil { + return nil, err + } + + var schedule Schedule + + err = doJSON(http.MethodGet, endpoint, nil, "schedule", &schedule) + if err != nil { + return nil, err + } + + return &schedule, nil +} + +// UpdateSchedule patches a schedule's cron expression and/or timezone. +func UpdateSchedule(pipelineID string, version int, scheduleID string, body ScheduleUpdateRequest) (*Schedule, error) { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules/"+scheduleID) + if err != nil { + return nil, err + } + + var result Schedule + + err = doJSON(http.MethodPatch, endpoint, body, "update schedule", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteSchedule removes a schedule. +func DeleteSchedule(pipelineID string, version int, scheduleID string) error { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules/"+scheduleID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete schedule") +} diff --git a/internal/pipeline/schedule_output.go b/internal/pipeline/schedule_output.go new file mode 100644 index 000000000..8f025248c --- /dev/null +++ b/internal/pipeline/schedule_output.go @@ -0,0 +1,169 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// schedule_output.go holds the rendering helpers shared by the +// `dr pipelines schedule` verbs. +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// scheduleJSON is the CLI-facing DTO used for `--output-format json`. +type scheduleJSON struct { + ScheduleID string `json:"schedule_id"` + PipelineID string `json:"pipeline_id"` + Version int `json:"version"` + CronExpression string `json:"cron_expression"` + Timezone string `json:"timezone"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toScheduleJSON(s Schedule) scheduleJSON { + return scheduleJSON{ + ScheduleID: s.ScheduleID, + PipelineID: s.PipelineID, + Version: s.Version, + CronExpression: s.CronExpression, + Timezone: s.Timezone, + Status: string(s.Status), + CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderSchedule routes a single schedule to JSON or human output. +func RenderSchedule(format OutputFormat, s Schedule) error { + if format == OutputFormatJSON { + return PrintScheduleJSON(s) + } + + PrintScheduleHuman(s) + + return nil +} + +// RenderSchedules routes a list of schedules to JSON or human output. +func RenderSchedules(format OutputFormat, items []Schedule) error { + if format == OutputFormatJSON { + return PrintScheduleListJSON(items) + } + + PrintScheduleListHuman(items) + + return nil +} + +// PrintScheduleJSON marshals a schedule as indented JSON through the DTO. +func PrintScheduleJSON(s Schedule) error { + data, err := json.MarshalIndent(toScheduleJSON(s), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintScheduleHuman renders a single schedule in human-friendly form. +func PrintScheduleHuman(s Schedule) { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Schedule ID:\t%s\n", s.ScheduleID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", s.PipelineID) + fmt.Fprintf(w, "Version:\tv%d\n", s.Version) + fmt.Fprintf(w, "Cron:\t%s\n", s.CronExpression) + fmt.Fprintf(w, "Timezone:\t%s\n", s.Timezone) + fmt.Fprintf(w, "Status:\t%s\n", string(s.Status)) + fmt.Fprintf(w, "Created:\t%s\n", s.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", s.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() +} + +// PrintScheduleListJSON marshals a list of schedules as indented JSON through the DTO. +func PrintScheduleListJSON(items []Schedule) error { + view := make([]scheduleJSON, len(items)) + + for i, s := range items { + view[i] = toScheduleJSON(s) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintScheduleListHuman renders a lipgloss table summary of schedules. +func PrintScheduleListHuman(items []Schedule) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No schedules found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"SCHEDULE ID", "VERSION", "CRON", "TIMEZONE", "STATUS", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, s := range items { + t.Row( + s.ScheduleID, + fmt.Sprintf("v%d", s.Version), + s.CronExpression, + s.Timezone, + string(s.Status), + s.UpdatedAt.UTC().Format(timestampFormat), + ) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} diff --git a/internal/pipeline/schedule_test.go b/internal/pipeline/schedule_test.go new file mode 100644 index 000000000..7f5070a51 --- /dev/null +++ b/internal/pipeline/schedule_test.go @@ -0,0 +1,142 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateSchedule_LockedOnlyURLAndBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules", r.URL.Path) + + var body ScheduleCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "0 * * * *", body.CronExpression) + assert.Equal(t, "in-1", body.PipelineInputID) + assert.Equal(t, "America/Los_Angeles", body.Timezone) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"0 * * * *","timezone":"America/Los_Angeles","status":"ACTIVE"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateSchedule("p-1", 2, ScheduleCreateRequest{ + CronExpression: "0 * * * *", + PipelineInputID: "in-1", + Timezone: "America/Los_Angeles", + }) + require.NoError(t, err) + assert.Equal(t, "s-1", got.ScheduleID) + assert.Equal(t, ScheduleStatusActive, got.Status) +} + +func TestListSchedules_QueryAndDecode(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules", r.URL.Path) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"0 0 * * *","timezone":"UTC","status":"ACTIVE"}],"totalCount":1,"count":1}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListSchedules("p-1", 2, 0, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "s-1", items[0].ScheduleID) +} + +func TestGetSchedule_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules/s-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"0 * * * *","timezone":"UTC","status":"PAUSED"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetSchedule("p-1", 2, "s-1") + require.NoError(t, err) + assert.Equal(t, ScheduleStatusPaused, got.Status) +} + +func TestUpdateSchedule_OmitsUnsuppliedFields(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules/s-1", r.URL.Path) + + var raw map[string]any + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&raw)) + // Only cron_expression should be in the body; timezone is omitted. + assert.Equal(t, "*/15 * * * *", raw["cron_expression"]) + _, hasTZ := raw["timezone"] + assert.False(t, hasTZ, "expected timezone to be omitted") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"*/15 * * * *","timezone":"UTC","status":"ACTIVE"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + cron := "*/15 * * * *" + got, err := UpdateSchedule("p-1", 2, "s-1", ScheduleUpdateRequest{CronExpression: &cron}) + require.NoError(t, err) + assert.Equal(t, "*/15 * * * *", got.CronExpression) +} + +func TestDeleteSchedule_DeletesLockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules/s-1", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeleteSchedule("p-1", 2, "s-1")) +} diff --git a/internal/pipeline/transport_test.go b/internal/pipeline/transport_test.go index b8a051bab..fb0b578b5 100644 --- a/internal/pipeline/transport_test.go +++ b/internal/pipeline/transport_test.go @@ -46,20 +46,6 @@ func installSkipAuth(t *testing.T) { }) } -// installEndpoint temporarily sets the DataRobot URL viper key to url, -// restoring the previous value at test cleanup. -func installEndpoint(t *testing.T, url string) { - t.Helper() - - prev := viperx.GetString(config.DataRobotURL) - - viperx.Set(config.DataRobotURL, url) - - t.Cleanup(func() { - viperx.Set(config.DataRobotURL, prev) - }) -} - func TestBuildJSONRequest_BodyAndHeaders(t *testing.T) { installSkipAuth(t)