diff --git a/Taskfile.yaml b/Taskfile.yaml index 895417211..460ac4c53 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -155,6 +155,22 @@ tasks: cmds: - ./smoke_test_scripts/run_smoke_test.sh {{if .DR_API_TOKEN}}{{.DR_API_TOKEN}}{{else}}$DR_API_TOKEN{{end}} + demo-pipelines: + desc: "End-to-end demo of every dr pipelines command. Defaults to interactive (keypress per step); pass DELAY=N or `-- --delay=N` for auto-advance. Set SKIP_SCHEDULES=true or SKIP_ENVIRONMENTS=true to skip steps that require k8s/Covalent." + deps: [build] + vars: + DEMO_DIR: '{{default "/Users/sunnypal.sharma/Desktop/tests/pipelines-api" .DEMO_DIR}}' + DELAY_FLAG: '{{if .DELAY}}--delay={{.DELAY}}{{end}}' + env: + DATAROBOT_CLI_FEATURE_PIPELINE: "true" + DATAROBOT_CLI_ENDPOINT: "http://localhost:8100/api/v2" + DATAROBOT_CLI_TOKEN: "local" + DATAROBOT_CLI_SKIP_AUTH: "true" + DEMO_SKIP_SCHEDULES: '{{if eq .SKIP_SCHEDULES "false"}}false{{else}}true{{end}}' + DEMO_SKIP_ENVIRONMENTS: '{{if eq .SKIP_ENVIRONMENTS "false"}}false{{else}}true{{end}}' + cmds: + - DR_BIN="$PWD/dist/dr" DEMO_DIR="{{.DEMO_DIR}}" bash "{{.DEMO_DIR}}/demo.sh" {{.DELAY_FLAG}} {{.CLI_ARGS}} 2>&1 | tee output.txt + smoke-test-self-update: desc: "Run self-update smoke tests" cmds: @@ -178,8 +194,8 @@ tasks: cmds: - echo "πŸ“š Starting documentation server…" - uv sync - - echo "🌐 Open http://localhost:8000 in your browser" - - uv run mkdocs serve + - echo "🌐 Open http://localhost:8001 in your browser" + - uv run mkdocs serve --dev-addr localhost:8001 copyright: silent: true diff --git a/cmd/pipeline/cmd.go b/cmd/pipeline/cmd.go new file mode 100644 index 000000000..06e9210e0 --- /dev/null +++ b/cmd/pipeline/cmd.go @@ -0,0 +1,65 @@ +// 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 ( + "github.com/datarobot/cli/cmd/pipeline/create" + "github.com/datarobot/cli/cmd/pipeline/del" + "github.com/datarobot/cli/cmd/pipeline/environment" + "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" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pipeline", + Aliases: []string{"pipelines"}, + GroupID: "core", + Short: "Pipelines API management commands", + Long: `Manage AI/ML pipelines orchestrated by Covalent. + +Create, list, inspect, and update pipelines registered with the +DataRobot pipelines service. Sub-commands are also available for managing +input payloads, runs, and recurring schedules.`, + } + + features.SetGate(cmd, "pipeline") + + cmd.AddCommand( + create.Cmd(), + get.Cmd(), + list.Cmd(), + update.Cmd(), + del.Cmd(), + lock.Cmd(), + version.Cmd(), + graph.Cmd(), + input.Cmd(), + run.Cmd(), + schedule.Cmd(), + environment.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go new file mode 100644 index 000000000..03280f159 --- /dev/null +++ b/cmd/pipeline/cmd_test.go @@ -0,0 +1,60 @@ +// 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 ( + "testing" + + "github.com/datarobot/cli/internal/features" + "github.com/stretchr/testify/assert" +) + +func TestCmd_BasicMetadata(t *testing.T) { + cmd := Cmd() + + assert.Equal(t, "pipeline", cmd.Use) + assert.Equal(t, "core", cmd.GroupID) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) +} + +func TestCmd_FeatureGate(t *testing.T) { + cmd := Cmd() + + gate, ok := cmd.Annotations[features.AnnotationKey] + assert.True(t, ok, "expected feature-gate annotation to be set") + assert.Equal(t, "pipeline", gate) +} + +func TestCmd_HasExpectedSubcommands(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "update": false, + } + + for _, sub := range cmd.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + + for name, found := range want { + assert.True(t, found, "expected subcommand %q to be registered", name) + } +} diff --git a/cmd/pipeline/create/cmd.go b/cmd/pipeline/create/cmd.go new file mode 100644 index 000000000..eb0a12b62 --- /dev/null +++ b/cmd/pipeline/create/cmd.go @@ -0,0 +1,97 @@ +// 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" + "fmt" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + description string + mode string + outputFormat pipeline.OutputFormat + fromFile string + ) + + cmd := &cobra.Command{ + Use: "create []", + Short: "Upload a Python file to create a pipeline.", + Long: `Upload a Python file containing a DataRobot pipeline (one or more tasks) to register a new pipeline. + +The pipeline name is extracted from the file and used as the pipeline's resource name. +By default, output is human-readable. Use --output-format json for machine-parseable output. + +The path to the Python file can be supplied either as a positional argument +or via the --from-file= flag. Exactly one of the two must be provided. + +Example: + dr pipeline create ./my_pipeline.py + dr pipeline create --from-file=./my_pipeline.py + dr pipeline create ./my_pipeline.py --description "First draft" --mode draft + dr pipeline create --from-file=./my_pipeline.py --output-format json`, + Args: cobra.MaximumNArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + RunE: func(_ *cobra.Command, args []string) error { + if mode != "" && mode != pipeline.ModeDraft && mode != pipeline.ModeLocked { + return fmt.Errorf("invalid mode: %s (supported: draft, locked)", mode) + } + + filePath, err := resolveFilePath(args, fromFile) + if err != nil { + return err + } + + result, err := pipeline.CreatePipeline(filePath, description, mode) + if err != nil { + return err + } + + return pipeline.RenderCreateResponse(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&description, "description", "", "Optional description for the pipeline") + cmd.Flags().StringVar(&mode, "mode", "", "Pipeline mode: draft (default) or locked") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} + +// resolveFilePath returns the file path supplied either positionally or via +// --from-file. Exactly one of the two must be provided. +func resolveFilePath(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 file path is required (positional argument or --from-file)") + } +} diff --git a/cmd/pipeline/create/cmd_test.go b/cmd/pipeline/create/cmd_test.go new file mode 100644 index 000000000..0e32b4081 --- /dev/null +++ b/cmd/pipeline/create/cmd_test.go @@ -0,0 +1,206 @@ +// 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 ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipeline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sampleCreateResponse() pipeline.CreateResponse { + return pipeline.CreateResponse{ + PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", + Name: "confluence_to_vdb", + Version: 1, + Status: "READY", + Mode: "draft", + TaskNames: []string{"create_vector_database", "ingest_confluence_files"}, + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + } +} + +func TestPrintCreateJSON(t *testing.T) { + resp := sampleCreateResponse() + + output := captureStdout(t, func() { + err := pipeline.RenderCreateResponse(pipeline.OutputFormatJSON, resp) + require.NoError(t, err) + }) + + var parsed map[string]interface{} + + err := json.Unmarshal([]byte(output), &parsed) + require.NoError(t, err) + assert.Equal(t, resp.PipelineID, parsed["id"]) + assert.Equal(t, resp.Name, parsed["name"]) + assert.Equal(t, "READY", parsed["status"]) + assert.Equal(t, "draft", parsed["mode"]) + assert.EqualValues(t, 1, parsed["version"]) +} + +func TestPrintCreateHuman_WithTasks(t *testing.T) { + resp := sampleCreateResponse() + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) + }) + + assert.Contains(t, output, resp.PipelineID) + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, "1") + assert.Contains(t, output, "READY") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "create_vector_database, ingest_confluence_files") +} + +func TestPrintCreateHuman_NoTasks(t *testing.T) { + resp := sampleCreateResponse() + resp.TaskNames = nil + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) + }) + + assert.Contains(t, output, "β€”") +} + +func TestCmd_RequiresFilePath(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil // bypass auth + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "a file path is required") +} + +func TestCmd_RejectsBothPositionalAndFromFile(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"a.py", "--from-file=b.py"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +// TestCmd_FromFileEqualsSyntax ensures the documented --from-file= +// form parses correctly (cobra accepts both `--from-file value` and +// `--from-file=value`; we exercise the equals form here). +func TestCmd_FromFileEqualsSyntax(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"--from-file=./my_pipeline.py"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + flag := cmd.Flags().Lookup("from-file") + require.NotNil(t, flag) + + err := cmd.ParseFlags([]string{"--from-file=./my_pipeline.py"}) + require.NoError(t, err) + assert.Equal(t, "./my_pipeline.py", flag.Value.String()) +} + +func TestResolveFilePath(t *testing.T) { + t.Run("positional only", func(t *testing.T) { + got, err := resolveFilePath([]string{"a.py"}, "") + require.NoError(t, err) + assert.Equal(t, "a.py", got) + }) + + t.Run("flag only", func(t *testing.T) { + got, err := resolveFilePath(nil, "b.py") + require.NoError(t, err) + assert.Equal(t, "b.py", got) + }) + + t.Run("both supplied", func(t *testing.T) { + _, err := resolveFilePath([]string{"a.py"}, "b.py") + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") + }) + + t.Run("neither supplied", func(t *testing.T) { + _, err := resolveFilePath(nil, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") + }) +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-file.py", "--output-format", "yaml"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil // bypass auth + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsInvalidMode(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-file.py", "--mode", "bogus"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil // bypass auth + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid mode") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"description", "mode", "output-format", "from-file"} { + flag := cmd.Flags().Lookup(name) + assert.NotNilf(t, flag, "expected --%s flag to be registered", name) + } +} diff --git a/cmd/pipeline/del/cmd.go b/cmd/pipeline/del/cmd.go new file mode 100644 index 000000000..44aa31dda --- /dev/null +++ b/cmd/pipeline/del/cmd.go @@ -0,0 +1,73 @@ +// 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 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/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline", + Long: `Delete a pipeline by id. The pipeline and all of its versions are +removed from the registry. + +Example: + dr pipeline delete 507f1f77bcf86cd799439011`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + err := pipeline.DeletePipeline(args[0]) + if err != nil { + return handleDeleteError(err, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted pipeline: " + args[0])) + + return nil + }, + } + + 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, pipelineID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No pipeline found with id: " + pipelineID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/del/cmd_test.go b/cmd/pipeline/del/cmd_test.go new file mode 100644 index 000000000..b9af06f1b --- /dev/null +++ b/cmd/pipeline/del/cmd_test.go @@ -0,0 +1,58 @@ +// 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 ( + "errors" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandleDeleteError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "http://x/api/v2/pipelines/abc"} + + err := handleDeleteError(httpErr, "abc") + assert.NoError(t, err) +} + +func TestHandleDeleteError_OtherStatusesPropagate(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusInternalServerError, URL: "http://x/api/v2/pipelines/abc"} + + err := handleDeleteError(httpErr, "abc") + require.Error(t, err) + + var got *drapi.HTTPError + + require.ErrorAs(t, err, &got) + assert.Equal(t, http.StatusInternalServerError, got.StatusCode) +} + +func TestHandleDeleteError_NonHTTPError(t *testing.T) { + err := handleDeleteError(errors.New("network down"), "abc") + require.Error(t, err) + assert.Contains(t, err.Error(), "network down") +} + +func TestCmd_RegistersExpectedShape(t *testing.T) { + cmd := Cmd() + + assert.Equal(t, "delete", cmd.Name()) + assert.NotNil(t, cmd.RunE) + assert.NotNil(t, cmd.PreRunE) +} diff --git a/cmd/pipeline/environment/cmd.go b/cmd/pipeline/environment/cmd.go new file mode 100644 index 000000000..68a19ddff --- /dev/null +++ b/cmd/pipeline/environment/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 environment + +import ( + "github.com/datarobot/cli/cmd/pipeline/environment/create" + "github.com/datarobot/cli/cmd/pipeline/environment/del" + "github.com/datarobot/cli/cmd/pipeline/environment/list" + "github.com/datarobot/cli/cmd/pipeline/environment/update" + "github.com/datarobot/cli/cmd/pipeline/environment/version" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline environment`. It +// groups the lifecycle verbs that operate on pipeline execution +// environments (named, immutable-versioned bags of pip packages). +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "environment", + Aliases: []string{"environments"}, + Short: "Manage pipeline execution environments", + Long: `Manage pipeline execution environments. + +Environments are named, immutable-versioned bags of pip packages that +pipelines can be built against. Each ` + "`update`" + ` adds packages by +creating a new version; older versions can be deleted individually with +` + "`environment version delete`" + `.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + update.Cmd(), + del.Cmd(), + version.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/environment/cmd_test.go b/cmd/pipeline/environment/cmd_test.go new file mode 100644 index 000000000..78456a7e7 --- /dev/null +++ b/cmd/pipeline/environment/cmd_test.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 environment + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "update": false, + "delete": false, + "version": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} + +func TestCmd_HasPluralAlias(t *testing.T) { + cmd := Cmd() + assert.Contains(t, cmd.Aliases, "environments") +} diff --git a/cmd/pipeline/environment/create/cmd.go b/cmd/pipeline/environment/create/cmd.go new file mode 100644 index 000000000..21ec77d45 --- /dev/null +++ b/cmd/pipeline/environment/create/cmd.go @@ -0,0 +1,73 @@ +// 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/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + name string + description string + rawPackages []string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a pipeline execution environment", + Long: `Create a new pipeline execution environment. + +A new environment is registered with an initial version (v1) containing +the supplied pip packages. The environment may be referenced by +pipelines once its first version reaches the READY state. + +Example: + dr pipeline environment create --name ml-base --package numpy --package pandas + dr pipeline environment create --name ml-base --packages numpy,pandas==2.0 --description "training base" --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if name == "" { + return errors.New("--name is required") + } + + packages, err := pipeline.NormalizePackages(rawPackages) + if err != nil { + return err + } + + result, err := pipeline.CreateEnvironment(name, description, packages) + if err != nil { + return err + } + + return pipeline.RenderEnvironment(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Environment name (required)") + cmd.Flags().StringVar(&description, "description", "", "Optional description") + cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} diff --git a/cmd/pipeline/environment/create/cmd_test.go b/cmd/pipeline/environment/create/cmd_test.go new file mode 100644 index 000000000..76ccb1d12 --- /dev/null +++ b/cmd/pipeline/environment/create/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 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, "--name", "x", "--package", "numpy", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingName(t *testing.T) { + err := runCmd(t, "--package", "numpy") + require.Error(t, err) + assert.Contains(t, err.Error(), "--name") +} + +func TestCmd_RejectsMissingPackages(t *testing.T) { + err := runCmd(t, "--name", "x") + require.Error(t, err) + assert.Contains(t, err.Error(), "package") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"name", "description", "package", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/environment/del/cmd.go b/cmd/pipeline/environment/del/cmd.go new file mode 100644 index 000000000..1f22a0560 --- /dev/null +++ b/cmd/pipeline/environment/del/cmd.go @@ -0,0 +1,59 @@ +// 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 `dr pipeline environment delete`. Directory +// is named `del` rather than `delete` to avoid shadowing Go's built-in +// `delete()` in importing files. + +package del + +import ( + "fmt" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline execution environment", + Long: `Soft-delete the most recent active version of a pipeline +execution environment. If no active versions remain after the delete, +the parent environment is soft-deleted too. + +To delete a specific older version, use: + dr pipeline environment version delete --environment + +Example: + dr pipeline environment delete env-123`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + err := pipeline.DeleteEnvironment(args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted environment: " + args[0])) + + return nil + }, + } + + return cmd +} diff --git a/cmd/pipeline/environment/del/cmd_test.go b/cmd/pipeline/environment/del/cmd_test.go new file mode 100644 index 000000000..0125db45b --- /dev/null +++ b/cmd/pipeline/environment/del/cmd_test.go @@ -0,0 +1,44 @@ +// 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_RequiresPositionalArg(t *testing.T) { + err := runCmd(t) + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "delete", Cmd().Name()) +} diff --git a/cmd/pipeline/environment/list/cmd.go b/cmd/pipeline/environment/list/cmd.go new file mode 100644 index 000000000..72109b6fb --- /dev/null +++ b/cmd/pipeline/environment/list/cmd.go @@ -0,0 +1,60 @@ +// 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/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline execution environments", + Long: `List pipeline execution environments. + +Returns a tabular view of registered environments, newest first. Each +row reflects the latest version's status only; per-version details are +returned by ` + "`environment create`" + ` and ` + "`environment update`" + `. + +Example: + dr pipeline environment list + dr pipeline environment list --offset 50 --limit 10 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + items, err := pipeline.ListEnvironments(offset, limit) + if err != nil { + return err + } + + return pipeline.RenderEnvironments(outputFormat, items) + }, + } + + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of environments to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} diff --git a/cmd/pipeline/environment/list/cmd_test.go b/cmd/pipeline/environment/list/cmd_test.go new file mode 100644 index 000000000..8bfe11528 --- /dev/null +++ b/cmd/pipeline/environment/list/cmd_test.go @@ -0,0 +1,49 @@ +// 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, "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"offset", "limit", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/environment/update/cmd.go b/cmd/pipeline/environment/update/cmd.go new file mode 100644 index 000000000..89715bc92 --- /dev/null +++ b/cmd/pipeline/environment/update/cmd.go @@ -0,0 +1,62 @@ +// 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/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + rawPackages []string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Add a new version to a pipeline execution environment", + Long: `Update a pipeline execution environment by appending packages. + +Updating creates a new immutable version of the environment containing +the supplied pip packages. Existing versions are unchanged. + +Example: + dr pipeline environment update env-123 --package scikit-learn + dr pipeline environment update env-123 --package "scikit-learn==1.5,torch" --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + packages, err := pipeline.NormalizePackages(rawPackages) + if err != nil { + return err + } + + result, err := pipeline.UpdateEnvironment(args[0], packages) + if err != nil { + return err + } + + return pipeline.RenderEnvironment(outputFormat, *result) + }, + } + + cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} diff --git a/cmd/pipeline/environment/update/cmd_test.go b/cmd/pipeline/environment/update/cmd_test.go new file mode 100644 index 000000000..df4d98890 --- /dev/null +++ b/cmd/pipeline/environment/update/cmd_test.go @@ -0,0 +1,60 @@ +// 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, "env-1", "--package", "numpy", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t, "--package", "numpy") + require.Error(t, err) +} + +func TestCmd_RejectsMissingPackages(t *testing.T) { + err := runCmd(t, "env-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "package") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"package", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/environment/version/cmd.go b/cmd/pipeline/environment/version/cmd.go new file mode 100644 index 000000000..590fcde49 --- /dev/null +++ b/cmd/pipeline/environment/version/cmd.go @@ -0,0 +1,34 @@ +// 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 version + +import ( + "github.com/datarobot/cli/cmd/pipeline/environment/version/del" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline environment version`. +// Currently only delete is exposed; the pipelines-api does not surface +// per-version GET endpoints. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Manage versions of a pipeline execution environment", + } + + cmd.AddCommand(del.Cmd()) + + return cmd +} diff --git a/cmd/pipeline/environment/version/cmd_test.go b/cmd/pipeline/environment/version/cmd_test.go new file mode 100644 index 000000000..695edee8b --- /dev/null +++ b/cmd/pipeline/environment/version/cmd_test.go @@ -0,0 +1,35 @@ +// 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 version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersDelete(t *testing.T) { + cmd := Cmd() + + found := false + + for _, sub := range cmd.Commands() { + if sub.Name() == "delete" { + found = true + } + } + + assert.True(t, found, "missing delete subcommand") +} diff --git a/cmd/pipeline/environment/version/del/cmd.go b/cmd/pipeline/environment/version/del/cmd.go new file mode 100644 index 000000000..4cf5f162a --- /dev/null +++ b/cmd/pipeline/environment/version/del/cmd.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. + +// Package del implements `dr pipeline environment version delete`. +// Directory is named `del` to avoid shadowing Go's built-in `delete()`. + +package del + +import ( + "errors" + "fmt" + "strconv" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var environmentID string + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a specific version of a pipeline execution environment", + Long: `Soft-delete a specific version of a pipeline execution environment +without touching the parent environment. + +Example: + dr pipeline environment version delete --environment env-123 2`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if environmentID == "" { + return errors.New("--environment is required") + } + + version, err := strconv.Atoi(args[0]) + if err != nil || version <= 0 { + return fmt.Errorf("invalid version: %q (expected a positive integer)", args[0]) + } + + err = pipeline.DeleteEnvironmentVersion(environmentID, version) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render( + fmt.Sprintf("Deleted environment version: %s v%d", environmentID, version), + )) + + return nil + }, + } + + cmd.Flags().StringVar(&environmentID, "environment", "", "Environment ID (required)") + + return cmd +} diff --git a/cmd/pipeline/environment/version/del/cmd_test.go b/cmd/pipeline/environment/version/del/cmd_test.go new file mode 100644 index 000000000..e1bd24223 --- /dev/null +++ b/cmd/pipeline/environment/version/del/cmd_test.go @@ -0,0 +1,60 @@ +// 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_RejectsMissingEnvironment(t *testing.T) { + err := runCmd(t, "1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--environment") +} + +func TestCmd_RejectsBadVersion(t *testing.T) { + err := runCmd(t, "--environment", "env-1", "abc") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") + + err = runCmd(t, "--environment", "env-1", "0") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t, "--environment", "env-1") + require.Error(t, err) +} + +func TestCmd_HasEnvironmentFlag(t *testing.T) { + assert.NotNil(t, Cmd().Flags().Lookup("environment")) +} diff --git a/cmd/pipeline/get/cmd.go b/cmd/pipeline/get/cmd.go new file mode 100644 index 000000000..4592dfdd6 --- /dev/null +++ b/cmd/pipeline/get/cmd.go @@ -0,0 +1,74 @@ +// 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/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var outputFormat pipeline.OutputFormat + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline.", + Long: `Display full details of a pipeline including all versions. + +By default, output is human-readable. Use --output-format json for machine-parseable output. + +Example: + dr pipeline get 507f1f77bcf86cd799439011 + dr pipeline get 507f1f77bcf86cd799439011 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + result, err := pipeline.GetPipeline(args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + return pipeline.RenderPipeline(outputFormat, *result) + }, + } + + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} + +// handleGetError translates a GetPipeline error into a user-facing message. +// A 404 is rendered as a friendly "No pipeline found" line on stdout and +// suppressed (returns nil) so the user does not see an HTTP-style stack +// or the command's usage on what is really an informational outcome. +func handleGetError(err error, pipelineID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No pipeline found with id: " + pipelineID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/get/cmd_test.go b/cmd/pipeline/get/cmd_test.go new file mode 100644 index 000000000..453b8aa01 --- /dev/null +++ b/cmd/pipeline/get/cmd_test.go @@ -0,0 +1,194 @@ +// 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 ( + "bytes" + "errors" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func samplePipeline() pipeline.Pipeline { + return pipeline.Pipeline{ + PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", + Name: "confluence_to_vdb", + Description: "test", + Mode: "draft", + IsActive: true, + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), + Versions: []pipeline.PipelineVersion{ + { + Version: 1, + Status: "READY", + TaskNames: []string{"create_vector_database", "ingest_confluence_files"}, + PythonVersion: "3.12", + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + }, + { + Version: 2, + Status: "FAILED", + PythonVersion: "3.12", + ErrorDetail: "boom", + CreatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), + }, + }, + } +} + +func TestPrintGetJSON(t *testing.T) { + p := samplePipeline() + + output := captureStdout(t, func() { + err := pipeline.RenderPipeline(pipeline.OutputFormatJSON, p) + require.NoError(t, err) + }) + + assert.Contains(t, output, `"id"`) + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, `"versions"`) +} + +func TestPrintGetHuman_RendersHeaderAndVersions(t *testing.T) { + p := samplePipeline() + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderPipeline(pipeline.OutputFormatText, p)) + }) + + assert.Contains(t, output, p.PipelineID) + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, "test") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "true") + assert.Contains(t, output, "Versions (2):") + assert.Contains(t, output, "VERSION") + assert.Contains(t, output, "STATUS") + assert.Contains(t, output, "PYTHON") + assert.Contains(t, output, "CREATED") + assert.Contains(t, output, "TASKS") + assert.Contains(t, output, "v1") + assert.Contains(t, output, "v2") + assert.Contains(t, output, "create_vector_database, ingest_confluence_files") + assert.Contains(t, output, "v2 error: boom") +} + +func TestPrintGetHuman_BlankDescriptionFallsBack(t *testing.T) { + p := samplePipeline() + p.Description = "" + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderPipeline(pipeline.OutputFormatText, p)) + }) + + assert.Contains(t, output, "β€”") +} + +func TestPrintGetHuman_NoVersions(t *testing.T) { + p := samplePipeline() + p.Versions = nil + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderPipeline(pipeline.OutputFormatText, p)) + }) + + assert.NotContains(t, output, "Versions (") +} + +func TestCmd_RequiresArg(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + require.Error(t, err) +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-id", "--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") +} + +func TestCmd_HasOutputFlag(t *testing.T) { + cmd := Cmd() + assert.NotNil(t, cmd.Flags().Lookup("output-format")) +} + +func TestHandleGetError_NotFoundPrintsFriendlyMessage(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: 404, URL: "http://example/api/v2/pipelines/abc"} + + output := captureStdout(t, func() { + err := handleGetError(httpErr, "abc") + assert.NoError(t, err) + }) + + assert.Contains(t, output, "No pipeline found with id: abc") +} + +func TestHandleGetError_OtherErrorsPassThrough(t *testing.T) { + otherHTTP := &drapi.HTTPError{StatusCode: 500, URL: "http://example/api/v2/pipelines/abc"} + + output := captureStdout(t, func() { + err := handleGetError(otherHTTP, "abc") + require.Error(t, err) + assert.Same(t, otherHTTP, err) + }) + + assert.NotContains(t, output, "No pipeline found") +} + +func TestHandleGetError_NonHTTPErrorPassesThrough(t *testing.T) { + plain := errors.New("network unreachable") + + err := handleGetError(plain, "abc") + require.Error(t, err) + assert.Equal(t, plain, err) +} diff --git a/cmd/pipeline/graph/cmd.go b/cmd/pipeline/graph/cmd.go new file mode 100644 index 000000000..91e5702c6 --- /dev/null +++ b/cmd/pipeline/graph/cmd.go @@ -0,0 +1,150 @@ +// 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 graph + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "text/tabwriter" + + "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/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "graph", + Short: "Display the DAG of a pipeline", + Long: `Display the pipeline/task graph (DAG) as either a JSON payload +(for visualisation tooling) or a human-readable summary. + +Scope is selected from the --scope/--version flags: + - no flags -> draft graph (latest version) + - --version=N -> locked graph for version N (scope auto-set) + - --scope=draft -> draft graph + - --scope=locked --version=N -> locked graph for version N + +Example: + dr pipeline graph --pipeline + dr pipeline graph --pipeline --version=2 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []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.GetGraph(flags.PipelineID, scope, version) + if err != nil { + return handleGraphError(err, flags.PipelineID) + } + + if outputFormat == pipeline.OutputFormatJSON { + return printGraphJSON(*result) + } + + printGraphHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} + +func handleGraphError(err error, pipelineID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No graph available for pipeline: " + pipelineID)) + + return nil + } + + return err +} + +func printGraphJSON(g pipeline.Graph) error { + data, err := json.MarshalIndent(g, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printGraphHuman(g pipeline.Graph) { + fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + g.Pipeline.Name)) + + if len(g.Nodes) == 0 { + fmt.Println(tui.DimStyle.Render("No nodes")) + + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Nodes (" + strconv.Itoa(len(g.Nodes)) + "):")) + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, " ID\tTYPE\tNAME") + + for _, n := range g.Nodes { + fmt.Fprintf(writer, " %d\t%s\t%s\n", n.ID, n.Type, n.Name) + } + + _ = writer.Flush() + + if len(g.Edges) == 0 { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Edges (" + strconv.Itoa(len(g.Edges)) + "):")) + + writer = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, " SOURCE\tTARGET") + + for _, e := range g.Edges { + fmt.Fprintf(writer, " %d\t%d\n", e.Source, e.Target) + } + + _ = writer.Flush() +} diff --git a/cmd/pipeline/graph/cmd_test.go b/cmd/pipeline/graph/cmd_test.go new file mode 100644 index 000000000..87f7700d3 --- /dev/null +++ b/cmd/pipeline/graph/cmd_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 graph + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sampleGraph() pipeline.Graph { + return pipeline.Graph{ + Pipeline: pipeline.GraphPipeline{Name: "wf", PythonVersion: "3.12"}, + Nodes: []pipeline.GraphNode{ + {ID: 0, Type: "function", Name: "wf"}, + {ID: 1, Type: "function", Name: "step1"}, + }, + Edges: []pipeline.GraphEdge{ + {Source: 0, Target: 1}, + }, + } +} + +func TestPrintGraphJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, printGraphJSON(sampleGraph())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + + pipeline, ok := parsed["lattice"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "wf", pipeline["name"]) + assert.Equal(t, "3.12", pipeline["pythonVersion"]) +} + +func TestPrintGraphHuman(t *testing.T) { + output := captureStdout(t, func() { + printGraphHuman(sampleGraph()) + }) + + assert.Contains(t, output, "Pipeline: wf") + assert.Contains(t, output, "Nodes (2):") + assert.Contains(t, output, "Edges (1):") + assert.Contains(t, output, "step1") +} + +func TestPrintGraphHuman_EmptyGraph(t *testing.T) { + output := captureStdout(t, func() { + printGraphHuman(pipeline.Graph{Pipeline: pipeline.GraphPipeline{Name: "empty"}}) + }) + + assert.Contains(t, output, "No nodes") +} + +func TestHandleGraphError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + + err := handleGraphError(httpErr, "abc") + assert.NoError(t, err) +} + +func TestHandleGraphError_PropagatesOther(t *testing.T) { + err := handleGraphError(errors.New("boom"), "abc") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"--pipeline", "p", "--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/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..70dcbbde9 --- /dev/null +++ b/cmd/pipeline/input/create/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 create + +import ( + "errors" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "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.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) + + 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..c460abbe8 --- /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..17812e7bf --- /dev/null +++ b/cmd/pipeline/input/del/cmd.go @@ -0,0 +1,70 @@ +// 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" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "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 { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + err = pipeline.DeleteInput(flags.PipelineID, scope, version, args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted input: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + + return cmd +} diff --git a/cmd/pipeline/input/del/cmd_test.go b/cmd/pipeline/input/del/cmd_test.go new file mode 100644 index 000000000..425d4d331 --- /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..2d3935a03 --- /dev/null +++ b/cmd/pipeline/input/get/cmd.go @@ -0,0 +1,82 @@ +// 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/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) + pipeline.AddOutputFlag(cmd, &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..9694f185d --- /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..1bb080284 --- /dev/null +++ b/cmd/pipeline/input/list/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 list + +import ( + "errors" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "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 { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + 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.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of inputs to return") + pipeline.AddOutputFlag(cmd, &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..5ccb8e15a --- /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..8dd0ab4fd --- /dev/null +++ b/cmd/pipeline/input/update/cmd.go @@ -0,0 +1,73 @@ +// 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/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 { + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + 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.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) + + 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..254ae5a59 --- /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/list/cmd.go b/cmd/pipeline/list/cmd.go new file mode 100644 index 000000000..7cc5f4e99 --- /dev/null +++ b/cmd/pipeline/list/cmd.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 list + +import ( + "fmt" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + mode string + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline.", + Long: `List pipelines registered with the pipelines service. + +By default, output is human-readable. Use --output-format json for machine-parseable output. + +Example: + dr pipeline list + dr pipeline list --mode draft + dr pipeline list --offset 0 --limit 50 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + RunE: func(_ *cobra.Command, _ []string) error { + if mode != "" && mode != pipeline.ModeDraft && mode != pipeline.ModeLocked { + return fmt.Errorf("invalid mode: %s (supported: draft, locked)", mode) + } + + list, err := pipeline.ListPipelines(mode, offset, limit) + if err != nil { + return err + } + + return pipeline.RenderPipelines(outputFormat, *list) + }, + } + + cmd.Flags().StringVar(&mode, "mode", "", "Filter by mode: draft or locked") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 50, "Pagination limit (1-200)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} diff --git a/cmd/pipeline/list/cmd_test.go b/cmd/pipeline/list/cmd_test.go new file mode 100644 index 000000000..512ba61ba --- /dev/null +++ b/cmd/pipeline/list/cmd_test.go @@ -0,0 +1,163 @@ +// 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 ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipeline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func intPtr(v int) *int { + return &v +} + +func sampleListResponse() pipeline.DataPage[pipeline.ListItem] { + return pipeline.DataPage[pipeline.ListItem]{ + Data: []pipeline.ListItem{ + { + PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", + Name: "confluence_to_vdb", + Mode: "draft", + IsActive: true, + LatestVersion: intPtr(3), + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), + }, + }, + TotalCount: 1, + Count: 1, + } +} + +func TestPrintListJSON(t *testing.T) { + list := sampleListResponse() + + output := captureStdout(t, func() { + err := pipeline.RenderPipelines(pipeline.OutputFormatJSON, list) + require.NoError(t, err) + }) + + var parsed []interface{} + + err := json.Unmarshal([]byte(output), &parsed) + require.NoError(t, err) + require.Len(t, parsed, 1) + + item := parsed[0].(map[string]interface{}) + assert.Equal(t, "confluence_to_vdb", item["name"]) + assert.Equal(t, "draft", item["mode"]) +} + +func TestPrintListHuman_Empty(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderPipelines(pipeline.OutputFormatText, pipeline.DataPage[pipeline.ListItem]{})) + }) + + assert.Contains(t, output, "No pipelines found.") +} + +func TestPrintListHuman_RendersHeaderAndRow(t *testing.T) { + list := sampleListResponse() + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderPipelines(pipeline.OutputFormatText, list)) + }) + + assert.Contains(t, output, "Showing 1 of 1") + assert.Contains(t, output, "ID") + assert.Contains(t, output, "NAME") + assert.Contains(t, output, "MODE") + assert.Contains(t, output, "ACTIVE") + assert.Contains(t, output, "VERSION") + assert.Contains(t, output, "UPDATED") + assert.Contains(t, output, "683c2a1b4f8e1a2b3c4d5e6f") + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "true") + assert.Contains(t, output, "v3") + assert.Contains(t, output, "2026-04-28") +} + +func TestPrintListHuman_NoLatestVersion(t *testing.T) { + list := sampleListResponse() + list.Data[0].LatestVersion = nil + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderPipelines(pipeline.OutputFormatText, list)) + }) + + assert.Contains(t, output, "β€”") +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"--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") +} + +func TestCmd_RejectsInvalidMode(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"--mode", "bogus"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid mode") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"mode", "offset", "limit", "output-format"} { + flag := cmd.Flags().Lookup(name) + assert.NotNilf(t, flag, "expected --%s flag to be registered", name) + } +} diff --git a/cmd/pipeline/lock/cmd.go b/cmd/pipeline/lock/cmd.go new file mode 100644 index 000000000..3c2e975df --- /dev/null +++ b/cmd/pipeline/lock/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 lock + +import ( + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var outputFormat pipeline.OutputFormat + + cmd := &cobra.Command{ + Use: "lock ", + Short: "Lock a draft pipeline", + Long: `Promote a draft pipeline to locked mode. Once locked, the pipeline can +no longer be updated and locked runs/inputs/schedules become valid. + +Example: + dr pipeline lock 507f1f77bcf86cd799439011 + dr pipeline lock 507f1f77bcf86cd799439011 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + result, err := pipeline.LockPipeline(args[0]) + if err != nil { + return err + } + + return pipeline.RenderCreateResponse(outputFormat, *result) + }, + } + + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} diff --git a/cmd/pipeline/lock/cmd_test.go b/cmd/pipeline/lock/cmd_test.go new file mode 100644 index 000000000..1b8398725 --- /dev/null +++ b/cmd/pipeline/lock/cmd_test.go @@ -0,0 +1,107 @@ +// 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 lock + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipeline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sample() pipeline.CreateResponse { + return pipeline.CreateResponse{ + PipelineID: "abc", + Name: "promo", + Version: 3, + Status: "READY", + Mode: "locked", + TaskNames: []string{"e1", "e2"}, + CreatedAt: time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC), + } +} + +func TestPrintLockJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatJSON, sample())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + assert.Equal(t, "abc", parsed["id"]) + assert.Equal(t, "locked", parsed["mode"]) + assert.EqualValues(t, 3, parsed["version"]) +} + +func TestPrintLockHuman(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, sample())) + }) + + assert.Contains(t, output, "abc") + assert.Contains(t, output, "locked") + assert.Contains(t, output, "3") + assert.Contains(t, output, "e1, e2") +} + +func TestPrintLockHuman_NoTasks(t *testing.T) { + resp := sample() + resp.TaskNames = nil + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) + }) + + assert.Contains(t, output, "β€”") +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"abc", "--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/cmd/pipeline/run/cancel/cmd.go b/cmd/pipeline/run/cancel/cmd.go new file mode 100644 index 000000000..b6345b1e9 --- /dev/null +++ b/cmd/pipeline/run/cancel/cmd.go @@ -0,0 +1,69 @@ +// 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 cancel + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var flags scopeflag.Flags + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a pipeline run", + Long: `Request cancellation of an in-flight run. + +The API rejects cancellation if the run has already reached a terminal +state (COMPLETED, FAILED, CANCELLED). + +Example: + dr pipeline run cancel --pipeline + dr pipeline run cancel --pipeline --version=2 `, + 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 + } + + err = pipeline.CancelRun(flags.PipelineID, scope, version, args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Cancelled run: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + + return cmd +} diff --git a/cmd/pipeline/run/cancel/cmd_test.go b/cmd/pipeline/run/cancel/cmd_test.go new file mode 100644 index 000000000..582095425 --- /dev/null +++ b/cmd/pipeline/run/cancel/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 cancel + +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, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "cancel", Cmd().Name()) +} diff --git a/cmd/pipeline/run/cmd.go b/cmd/pipeline/run/cmd.go new file mode 100644 index 000000000..bd2bc50da --- /dev/null +++ b/cmd/pipeline/run/cmd.go @@ -0,0 +1,49 @@ +// 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 run + +import ( + "github.com/datarobot/cli/cmd/pipeline/run/cancel" + "github.com/datarobot/cli/cmd/pipeline/run/create" + "github.com/datarobot/cli/cmd/pipeline/run/get" + "github.com/datarobot/cli/cmd/pipeline/run/list" + "github.com/datarobot/cli/cmd/pipeline/run/status" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline run`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "Manage pipeline runs", + Long: `Trigger and inspect runs (single executions) of a pipeline. + +Runs come in two scopes: + - draft : executes against the in-flight draft of a pipeline + - locked : executes against a specific frozen version + +When --version is supplied, the locked scope is selected automatically.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + status.Cmd(), + cancel.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/run/cmd_test.go b/cmd/pipeline/run/cmd_test.go new file mode 100644 index 000000000..ce2c30cff --- /dev/null +++ b/cmd/pipeline/run/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 run + +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, + "status": false, + "cancel": 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/run/create/cmd.go b/cmd/pipeline/run/create/cmd.go new file mode 100644 index 000000000..cbf5d957d --- /dev/null +++ b/cmd/pipeline/run/create/cmd.go @@ -0,0 +1,75 @@ +// 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/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + inputID string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Trigger a pipeline run", + Long: `Trigger a new run (single execution) of a pipeline. + +The run is created in PENDING state. Use ` + "`dr pipeline run get`" + ` +or ` + "`dr pipeline run status`" + ` to follow its progress. + +Example: + dr pipeline run create --pipeline --input + dr pipeline run create --pipeline --version=2 --input --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + if inputID == "" { + return errors.New("--input is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.CreateRun(flags.PipelineID, scope, version, inputID) + if err != nil { + return err + } + + return pipeline.RenderRun(outputFormat, *result) + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} diff --git a/cmd/pipeline/run/create/cmd_test.go b/cmd/pipeline/run/create/cmd_test.go new file mode 100644 index 000000000..925cc6e26 --- /dev/null +++ b/cmd/pipeline/run/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", "--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, "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsMissingInput(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "--input") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--scope", "draft", "--version", "2") + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "input", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/run/get/cmd.go b/cmd/pipeline/run/get/cmd.go new file mode 100644 index 000000000..0ad1caf1c --- /dev/null +++ b/cmd/pipeline/run/get/cmd.go @@ -0,0 +1,82 @@ +// 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/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 run", + Long: `Display the full record for a single run. + +Example: + dr pipeline run get --pipeline + dr pipeline run 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.GetRun(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + return pipeline.RenderRun(outputFormat, *result) + }, + } + + flags.Bind(cmd) + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} + +func handleGetError(err error, runID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No run found with id: " + runID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/run/get/cmd_test.go b/cmd/pipeline/run/get/cmd_test.go new file mode 100644 index 000000000..a7fe10af8 --- /dev/null +++ b/cmd/pipeline/run/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", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-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, "d-1")) +} + +func TestHandleGetError_PropagatesOther(t *testing.T) { + err := handleGetError(errors.New("boom"), "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipeline/run/list/cmd.go b/cmd/pipeline/run/list/cmd.go new file mode 100644 index 000000000..3f9ed238c --- /dev/null +++ b/cmd/pipeline/run/list/cmd.go @@ -0,0 +1,70 @@ +// 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/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "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 runs", + Long: `List runs for a pipeline. + +Example: + dr pipeline run list --pipeline + dr pipeline run list --pipeline --version=2 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + items, err := pipeline.ListRuns(flags.PipelineID, scope, version, offset, limit) + if err != nil { + return err + } + + return pipeline.RenderRuns(outputFormat, items) + }, + } + + flags.Bind(cmd) + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of runs to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} diff --git a/cmd/pipeline/run/list/cmd_test.go b/cmd/pipeline/run/list/cmd_test.go new file mode 100644 index 000000000..5ccb8e15a --- /dev/null +++ b/cmd/pipeline/run/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/run/status/cmd.go b/cmd/pipeline/run/status/cmd.go new file mode 100644 index 000000000..4ac8e3d87 --- /dev/null +++ b/cmd/pipeline/run/status/cmd.go @@ -0,0 +1,82 @@ +// 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 status + +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/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "status ", + Short: "Get the lightweight status of a pipeline run", + Long: `Poll a run's current status without re-downloading the full record. + +Example: + dr pipeline run status --pipeline + dr pipeline run status --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.GetRunStatus(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleStatusError(err, args[0]) + } + + return pipeline.RenderRunStatus(outputFormat, *result) + }, + } + + flags.Bind(cmd) + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} + +func handleStatusError(err error, runID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No run found with id: " + runID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/run/status/cmd_test.go b/cmd/pipeline/run/status/cmd_test.go new file mode 100644 index 000000000..061cb698b --- /dev/null +++ b/cmd/pipeline/run/status/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 status + +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", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestHandleStatusError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleStatusError(httpErr, "d-1")) +} + +func TestHandleStatusError_PropagatesOther(t *testing.T) { + err := handleStatusError(errors.New("boom"), "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} 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..5e4d05c1c --- /dev/null +++ b/cmd/pipeline/schedule/create/cmd.go @@ -0,0 +1,86 @@ +// 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/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 pipelineID == "" { + return errors.New("--pipeline is required") + } + + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + if cron == "" { + return errors.New("--cron is required") + } + + if inputID == "" { + return errors.New("--input is required") + } + + 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.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + cmd.Flags().StringVar(&cron, "cron", "", "Cron expression, e.g. \"0 * * * *\"") + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to run on each tick") + cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") + pipeline.AddOutputFlag(cmd, &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..e835f86ec --- /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_RejectsZeroVersion(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..ea8c511f1 --- /dev/null +++ b/cmd/pipeline/schedule/del/cmd.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. + +// 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" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "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 pipelineID == "" { + return errors.New("--pipeline is required") + } + + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + err := pipeline.DeleteSchedule(pipelineID, version, args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted schedule: " + args[0])) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + + return cmd +} diff --git a/cmd/pipeline/schedule/del/cmd_test.go b/cmd/pipeline/schedule/del/cmd_test.go new file mode 100644 index 000000000..5eb4167f7 --- /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_RejectsZeroVersion(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..0b2540e8b --- /dev/null +++ b/cmd/pipeline/schedule/get/cmd.go @@ -0,0 +1,82 @@ +// 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/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 pipelineID == "" { + return errors.New("--pipeline is required") + } + + 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.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + pipeline.AddOutputFlag(cmd, &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..71b9d5609 --- /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_RejectsZeroVersion(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..41eaa8639 --- /dev/null +++ b/cmd/pipeline/schedule/list/cmd.go @@ -0,0 +1,70 @@ +// 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/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 pipelineID == "" { + return errors.New("--pipeline is required") + } + + 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.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of schedules to return") + pipeline.AddOutputFlag(cmd, &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..882700350 --- /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_RejectsZeroVersion(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..a31d7110d --- /dev/null +++ b/cmd/pipeline/schedule/update/cmd.go @@ -0,0 +1,103 @@ +// 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/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.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + cmd.Flags().StringVar(&cron, "cron", "", "New cron expression") + cmd.Flags().StringVar(&timezone, "timezone", "", "New IANA timezone name") + pipeline.AddOutputFlag(cmd, &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, pipelineID string, version int, cron, timezone string) (pipeline.ScheduleUpdateRequest, error) { + if pipelineID == "" { + return pipeline.ScheduleUpdateRequest{}, errors.New("--pipeline is required") + } + + 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") + } + + 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..9cfc631a6 --- /dev/null +++ b/cmd/pipeline/schedule/update/cmd_test.go @@ -0,0 +1,108 @@ +// 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_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_RejectsMissingPipeline(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--cron=0 0 * * *"})) + + _, err := buildUpdateBody(cmd, "", 2, "0 0 * * *", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +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/cmd/pipeline/scopeflag/scopeflag.go b/cmd/pipeline/scopeflag/scopeflag.go new file mode 100644 index 000000000..a382b13b2 --- /dev/null +++ b/cmd/pipeline/scopeflag/scopeflag.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 scopeflag bundles the --pipeline / --scope / --version flags +// reused by the input and run CLI command groups. + +package scopeflag + +import ( + "github.com/datarobot/cli/internal/pipeline" + "github.com/spf13/cobra" +) + +// Flags holds the values backing the shared --pipeline / --scope / +// --version flags. Bind() registers them on a cobra command and +// Resolve(cmd) turns them into a (Scope, *version) pair via +// pipeline.ResolveScope. +type Flags struct { + PipelineID string + Scope string + Version int +} + +// Bind registers --pipeline, --scope and --version on cmd. The caller is +// responsible for marking --pipeline required if appropriate. +func (f *Flags) Bind(cmd *cobra.Command) { + cmd.Flags().StringVar(&f.PipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().StringVar(&f.Scope, "scope", "", "Scope: draft (default) or locked (auto-set when --version is supplied)") + cmd.Flags().IntVar(&f.Version, "version", 0, "Pipeline version (implies --scope=locked)") +} + +// Resolve combines the parsed flags into the canonical (Scope, *version) +// pair required by the pipelines client. It must be called from RunE (or +// later) so cmd.Flags().Changed has accurate state. +func (f *Flags) Resolve(cmd *cobra.Command) (pipeline.Scope, *int, error) { + var version *int + + if cmd.Flags().Changed("version") { + v := f.Version + + version = &v + } + + return pipeline.ResolveScope(f.Scope, version) +} diff --git a/cmd/pipeline/scopeflag/scopeflag_test.go b/cmd/pipeline/scopeflag/scopeflag_test.go new file mode 100644 index 000000000..1a1c96864 --- /dev/null +++ b/cmd/pipeline/scopeflag/scopeflag_test.go @@ -0,0 +1,115 @@ +// 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 scopeflag + +import ( + "io" + "testing" + + "github.com/datarobot/cli/internal/pipeline" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newCmd builds a cobra command with the shared flags bound, so tests can +// drive Resolve() through real flag parsing. +func newCmd() (*cobra.Command, *Flags) { + flags := &Flags{} + + cmd := &cobra.Command{ + Use: "test", + RunE: func(_ *cobra.Command, _ []string) error { + return nil + }, + } + + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + flags.Bind(cmd) + + return cmd, flags +} + +func TestFlags_Resolve_DefaultDraft(t *testing.T) { + cmd, flags := newCmd() + + cmd.SetArgs([]string{}) + require.NoError(t, cmd.Execute()) + + scope, version, err := flags.Resolve(cmd) + require.NoError(t, err) + assert.Equal(t, pipeline.ScopeDraft, scope) + assert.Nil(t, version) +} + +func TestFlags_Resolve_VersionImpliesLocked(t *testing.T) { + cmd, flags := newCmd() + + cmd.SetArgs([]string{"--version=4"}) + require.NoError(t, cmd.Execute()) + + scope, version, err := flags.Resolve(cmd) + require.NoError(t, err) + assert.Equal(t, pipeline.ScopeLocked, scope) + require.NotNil(t, version) + assert.Equal(t, 4, *version) +} + +func TestFlags_Resolve_ExplicitDraftWithVersionErrors(t *testing.T) { + cmd, flags := newCmd() + + cmd.SetArgs([]string{"--scope=draft", "--version=1"}) + require.NoError(t, cmd.Execute()) + + _, _, err := flags.Resolve(cmd) + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestFlags_Resolve_LockedRequiresVersion(t *testing.T) { + cmd, flags := newCmd() + + cmd.SetArgs([]string{"--scope=locked"}) + require.NoError(t, cmd.Execute()) + + _, _, err := flags.Resolve(cmd) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestFlags_Resolve_VersionZeroIsRespected(t *testing.T) { + // 0 is the int zero-value, but the user explicitly passed --version=0 + // so we should treat it as locked v0 (server will validate). + cmd, flags := newCmd() + + cmd.SetArgs([]string{"--version=0"}) + require.NoError(t, cmd.Execute()) + + scope, version, err := flags.Resolve(cmd) + require.NoError(t, err) + assert.Equal(t, pipeline.ScopeLocked, scope) + require.NotNil(t, version) + assert.Equal(t, 0, *version) +} + +func TestFlags_Bind_RegistersAllFlags(t *testing.T) { + cmd, _ := newCmd() + + for _, name := range []string{"pipeline", "scope", "version"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s to be registered", name) + } +} diff --git a/cmd/pipeline/update/cmd.go b/cmd/pipeline/update/cmd.go new file mode 100644 index 000000000..535fc44f7 --- /dev/null +++ b/cmd/pipeline/update/cmd.go @@ -0,0 +1,92 @@ +// 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/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + outputFormat pipeline.OutputFormat + fromFile string + ) + + cmd := &cobra.Command{ + Use: "update []", + Short: "Re-upload a Python file to update a draft pipeline.", + Long: `Update an existing draft pipeline by re-uploading a Python file. + +A new version is appended to the pipeline. The pipeline name encoded in the +uploaded file must match the existing pipeline name. Locked pipelines cannot +be updated. + +The path to the Python file can be supplied either as a positional argument +or via the --from-file= flag. Exactly one of the two must be provided. + +By default, output is human-readable. Use --output-format json for machine-parseable output. + +Example: + dr pipeline update 507f1f77bcf86cd799439011 ./my_pipeline.py + dr pipeline update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py + dr pipeline update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py --output-format json`, + Args: cobra.RangeArgs(1, 2), + PreRunE: auth.EnsureAuthenticatedE, + RunE: func(_ *cobra.Command, args []string) error { + pipelineID := args[0] + + filePath, err := resolveFilePath(args[1:], fromFile) + if err != nil { + return err + } + + result, err := pipeline.UpdatePipeline(pipelineID, filePath) + if err != nil { + return err + } + + return pipeline.RenderCreateResponse(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} + +// resolveFilePath returns the file path supplied either positionally (in +// extraArgs) or via --from-file. Exactly one of the two must be provided. +func resolveFilePath(extraArgs []string, fromFile string) (string, error) { + positional := "" + if len(extraArgs) > 0 { + positional = extraArgs[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 file path is required (positional argument or --from-file)") + } +} diff --git a/cmd/pipeline/update/cmd_test.go b/cmd/pipeline/update/cmd_test.go new file mode 100644 index 000000000..97efd7512 --- /dev/null +++ b/cmd/pipeline/update/cmd_test.go @@ -0,0 +1,202 @@ +// 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 ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipeline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sampleUpdateResponse() pipeline.CreateResponse { + return pipeline.CreateResponse{ + PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", + Name: "confluence_to_vdb", + Version: 2, + Status: "READY", + Mode: "draft", + TaskNames: []string{"create_vector_database"}, + CreatedAt: time.Date(2026, 4, 28, 12, 24, 54, 0, time.UTC), + } +} + +func TestPrintUpdateJSON(t *testing.T) { + resp := sampleUpdateResponse() + + output := captureStdout(t, func() { + err := pipeline.RenderCreateResponse(pipeline.OutputFormatJSON, resp) + require.NoError(t, err) + }) + + var parsed map[string]interface{} + + err := json.Unmarshal([]byte(output), &parsed) + require.NoError(t, err) + assert.Equal(t, resp.PipelineID, parsed["id"]) + assert.EqualValues(t, 2, parsed["version"]) + assert.Equal(t, "READY", parsed["status"]) +} + +func TestPrintUpdateHuman_WithTasks(t *testing.T) { + resp := sampleUpdateResponse() + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) + }) + + assert.Contains(t, output, resp.PipelineID) + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, "2") + assert.Contains(t, output, "READY") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "create_vector_database") +} + +func TestPrintUpdateHuman_NoTasks(t *testing.T) { + resp := sampleUpdateResponse() + resp.TaskNames = nil + + output := captureStdout(t, func() { + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) + }) + + assert.Contains(t, output, "β€”") +} + +func TestCmd_RequiresPipelineID(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) +} + +func TestCmd_RequiresFilePath(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-id"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "a file path is required") +} + +func TestCmd_RejectsBothPositionalAndFromFile(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-id", "a.py", "--from-file=b.py"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-id", "some-file.py", "--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") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"output-format", "from-file"} { + flag := cmd.Flags().Lookup(name) + assert.NotNilf(t, flag, "expected --%s flag to be registered", name) + } +} + +// TestCmd_FromFileEqualsSyntax ensures the documented --from-file= +// form parses correctly (cobra accepts both `--from-file value` and +// `--from-file=value`; we exercise the equals form here). +func TestCmd_FromFileEqualsSyntax(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.ParseFlags([]string{"--from-file=./my_pipeline.py"}) + require.NoError(t, err) + + flag := cmd.Flags().Lookup("from-file") + require.NotNil(t, flag) + assert.Equal(t, "./my_pipeline.py", flag.Value.String()) +} + +func TestResolveFilePath(t *testing.T) { + t.Run("positional only", func(t *testing.T) { + got, err := resolveFilePath([]string{"a.py"}, "") + require.NoError(t, err) + assert.Equal(t, "a.py", got) + }) + + t.Run("flag only", func(t *testing.T) { + got, err := resolveFilePath(nil, "b.py") + require.NoError(t, err) + assert.Equal(t, "b.py", got) + }) + + t.Run("both supplied", func(t *testing.T) { + _, err := resolveFilePath([]string{"a.py"}, "b.py") + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") + }) + + t.Run("neither supplied", func(t *testing.T) { + _, err := resolveFilePath(nil, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") + }) +} diff --git a/cmd/pipeline/version/cmd.go b/cmd/pipeline/version/cmd.go new file mode 100644 index 000000000..2008f4f89 --- /dev/null +++ b/cmd/pipeline/version/cmd.go @@ -0,0 +1,40 @@ +// 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 version + +import ( + "github.com/datarobot/cli/cmd/pipeline/version/get" + "github.com/datarobot/cli/cmd/pipeline/version/list" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline version`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Inspect pipeline versions", + Long: `Read-only access to pipeline versions. + +Versions are also surfaced inline by ` + "`dr pipeline get`" + `; this group +provides a paginated list and a single-version detail view.`, + } + + cmd.AddCommand( + list.Cmd(), + get.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/version/cmd_test.go b/cmd/pipeline/version/cmd_test.go new file mode 100644 index 000000000..217fbac5a --- /dev/null +++ b/cmd/pipeline/version/cmd_test.go @@ -0,0 +1,38 @@ +// 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 version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "list": false, + "get": 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/version/get/cmd.go b/cmd/pipeline/version/get/cmd.go new file mode 100644 index 000000000..0f17a0bad --- /dev/null +++ b/cmd/pipeline/version/get/cmd.go @@ -0,0 +1,82 @@ +// 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" + "strconv" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a single pipeline version", + Long: `Display the pipeline metadata for a single pipeline version. + +Example: + dr pipeline version get --pipeline 2 + dr pipeline version get --pipeline 2 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + versionID, err := strconv.Atoi(args[0]) + if err != nil || versionID <= 0 { + return fmt.Errorf("invalid version: %q (expected a positive integer)", args[0]) + } + + result, err := pipeline.GetVersion(pipelineID, versionID) + if err != nil { + return handleGetError(err, args[0]) + } + + return pipeline.RenderVersion(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} + +func handleGetError(err error, versionLabel string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No version found: " + versionLabel)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/version/get/cmd_test.go b/cmd/pipeline/version/get/cmd_test.go new file mode 100644 index 000000000..f698944fe --- /dev/null +++ b/cmd/pipeline/version/get/cmd_test.go @@ -0,0 +1,75 @@ +// 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 TestCmd_RejectsMissingPipelineFlag(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"2"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsNonNumericVersion(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"abc", "--pipeline", "p"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") +} + +func TestCmd_RejectsZeroOrNegativeVersion(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"0", "--pipeline", "p"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + + err := handleGetError(httpErr, "2") + assert.NoError(t, err) +} + +func TestHandleGetError_OtherErrorsPropagate(t *testing.T) { + err := handleGetError(errors.New("boom"), "2") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipeline/version/list/cmd.go b/cmd/pipeline/version/list/cmd.go new file mode 100644 index 000000000..f55d6e987 --- /dev/null +++ b/cmd/pipeline/version/list/cmd.go @@ -0,0 +1,64 @@ +// 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/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List versions of a pipeline", + Long: `List versions of a pipeline (paginated). + +Example: + dr pipeline version list --pipeline + dr pipeline version list --pipeline --offset 10 --limit 5 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + items, err := pipeline.ListVersions(pipelineID, offset, limit) + if err != nil { + return err + } + + return pipeline.RenderVersions(outputFormat, items) + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of versions to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + return cmd +} diff --git a/cmd/pipeline/version/list/cmd_test.go b/cmd/pipeline/version/list/cmd_test.go new file mode 100644 index 000000000..47e77e50f --- /dev/null +++ b/cmd/pipeline/version/list/cmd_test.go @@ -0,0 +1,55 @@ +// 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_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "offset", "limit", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/root.go b/cmd/root.go index 78b04bb99..420220416 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( "github.com/datarobot/cli/cmd/component" "github.com/datarobot/cli/cmd/dependencies" "github.com/datarobot/cli/cmd/dotenv" + "github.com/datarobot/cli/cmd/pipeline" "github.com/datarobot/cli/cmd/plugin" "github.com/datarobot/cli/cmd/self" "github.com/datarobot/cli/cmd/start" @@ -217,6 +218,7 @@ func init() { templates.Cmd(), workload.Cmd(), plugin.Cmd(), + pipeline.Cmd(), ) // Discover and register plugin commands diff --git a/docs/commands/README.md b/docs/commands/README.md index 8bcf2ada8..e8b3650c1 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -41,6 +41,7 @@ These flags are available for all commands: | [`dotenv`](dotenv.md) | Manage environment variables. | | [`self`](self.md) | CLI utility commands (update, version, completion, plugin). | | [`plugin`](plugins.md) | Inspect and manage CLI plugins. | +| [`pipeline`](pipeline.md) | Manage pipelines via the pipelines API (feature-gated). | | [`dependencies`](dependencies.md) | Check and install template dependencies (advanced). | ### Command tree @@ -74,6 +75,42 @@ dr β”‚ β”œβ”€β”€ install Install a plugin β”‚ β”œβ”€β”€ uninstall Uninstall a plugin β”‚ └── update Update plugins +β”œβ”€β”€ pipelines Pipelines API management (feature-gated) +β”‚ β”œβ”€β”€ create Upload a Python file to create a pipeline +β”‚ β”œβ”€β”€ list List pipelines +β”‚ β”œβ”€β”€ get Display pipeline details and versions +β”‚ β”œβ”€β”€ update Re-upload a Python file to update a draft pipeline +β”‚ β”œβ”€β”€ delete Delete a pipeline and all of its versions +β”‚ β”œβ”€β”€ lock Promote a draft pipeline to locked mode +β”‚ β”œβ”€β”€ version Inspect pipeline versions +β”‚ β”‚ β”œβ”€β”€ list List versions of a pipeline +β”‚ β”‚ └── get Display details of a single pipeline version +β”‚ β”œβ”€β”€ graph Display the pipeline/task DAG of a pipeline +β”‚ β”œβ”€β”€ 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 +β”‚ β”œβ”€β”€ 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 +β”‚ β”œβ”€β”€ 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 +β”‚ └── environment Manage pipeline execution environments (pip packages) +β”‚ β”œβ”€β”€ create Register a new environment with an initial version +β”‚ β”œβ”€β”€ list List environments +β”‚ β”œβ”€β”€ update Add packages by creating a new version +β”‚ β”œβ”€β”€ delete Soft-delete the latest version (cascades parent) +β”‚ └── version Manage individual environment versions +β”‚ └── delete Delete a specific environment version └── self CLI utility commands β”œβ”€β”€ completion Shell completion β”‚ β”œβ”€β”€ install Install completions interactively @@ -235,6 +272,20 @@ For detailed documentation on each command, see: - **[plugin](plugins.md)**—inspect and manage installed CLI plugins (alias: `plugins`). +- **[pipeline](pipeline.md)**—manage AI/ML pipelines orchestrated by Covalent (feature-gated behind `DATAROBOT_CLI_FEATURE_PIPELINE=true`). See the [pipeline reference](pipeline-reference.md) for an exhaustive endpoint mapping. + - `create`—upload a Python file to register a new pipeline. + - `list`—list pipelines with mode filtering and pagination. + - `get`—display full details of a pipeline including all versions. + - `update`—re-upload a Python file to append a new version to a draft pipeline. + - `delete`—remove a pipeline and all of its versions. + - `lock`—promote a draft pipeline to locked mode. + - `version`—`list` / `get` to inspect pipeline versions. + - `graph`—display the pipeline/task DAG (draft or locked). + - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by runs. + - `run`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. + - `schedule`—`create`/`list`/`get`/`update`/`delete` recurring (cron) runs on locked versions. + - `environment`—`create`/`list`/`update`/`delete` named, immutable-versioned pip-package execution environments; `environment version delete` removes a specific older version. + ## Getting help ```bash diff --git a/docs/commands/pipeline-reference.md b/docs/commands/pipeline-reference.md new file mode 100644 index 000000000..26d1ca104 --- /dev/null +++ b/docs/commands/pipeline-reference.md @@ -0,0 +1,210 @@ + +# `dr pipeline` command reference + +Complete cross-reference of every `dr pipeline …` subcommand, the +`pipelines-api` endpoint each one calls, sample invocations, and the +inputs (positional args, flags, request body fields) each command +accepts. + +> All commands below assume the `pipelines` feature is enabled +> (`DATAROBOT_CLI_FEATURE_PIPELINE=true`). + +## How to read this document + +- **Method + path** is relative to `/api/v2`. The CLI prefixes the host + from `DATAROBOT_CLI_ENDPOINT` (or `DATAROBOT_ENDPOINT`). +- **Usage** lists the canonical invocation plus common variants. +- **Inputs** names every positional argument and flag the command + accepts. Flags shared by many commands (`--output`, `--scope`, + `--version`, `--from-file`, `--skip-auth`) are described once at the + bottom under "Shared flag semantics". + +--- + +## Pipeline lifecycle + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline create` | `POST /pipelines` | `dr pipeline create ./my_pipeline.py`
`dr pipeline create --from-file=./my_pipeline.py`
`dr pipeline create ./my_pipeline.py --description "First draft" --mode draft`
`dr pipeline create --from-file=./my_pipeline.py --output json` | **Positional:** `` (Python file defining a DataRobot pipeline; mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--description `, `--mode draft\|locked`, `--output json`. | +| `dr pipeline list` | `GET /pipelines` | `dr pipeline list`
`dr pipeline list --mode draft`
`dr pipeline list --offset 50 --limit 10 --output json` | **Flags:** `--mode draft\|locked`, `--offset `, `--limit `, `--output json`. | +| `dr pipeline get` | `GET /pipelines/{pipeline_id}` | `dr pipeline get `
`dr pipeline get --output json` | **Positional:** `` (required).
**Flags:** `--output json`. | +| `dr pipeline update` | `PATCH /pipelines/{pipeline_id}` | `dr pipeline update ./my_pipeline.py`
`dr pipeline update --from-file=./my_pipeline.py`
`dr pipeline update --from-file=./my_pipeline.py --output json` | **Positional:** `` (required), `` (mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--output json`. | +| `dr pipeline delete` | `DELETE /pipelines/{pipeline_id}` | `dr pipeline delete ` | **Positional:** `` (required). | +| `dr pipeline lock` | `PATCH /pipelines/{pipeline_id}/mode` | `dr pipeline lock `
`dr pipeline lock --output json` | **Positional:** `` (required).
**Flags:** `--output json`.
**Body:** none (the API uses absence-of-body as the promote signal). | + +--- + +## Versions + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline version list` | `GET /pipelines/{pipeline_id}/versions` | `dr pipeline version list --pipeline `
`dr pipeline version list --pipeline --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--offset `, `--limit `, `--output json`. | +| `dr pipeline version get` | `GET /pipelines/{pipeline_id}/versions/{version_id}` | `dr pipeline version get --pipeline 2`
`dr pipeline version get --pipeline 2 --output json` | **Positional:** `` (positive integer, required).
**Flags:** `--pipeline ` (required), `--output json`. | +| `dr pipeline graph` | `GET /pipelines/{pipeline_id}/graph` (draft)
`GET /pipelines/{pipeline_id}/versions/{version_id}/graph` (locked) | `dr pipeline graph --pipeline ` (draft)
`dr pipeline graph --pipeline --version=2` (locked)
`dr pipeline graph --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope draft\|locked`, `--version `, `--output json`. | + +--- + +## Inputs (`dr pipeline input …`) + +Inputs come in two scopes β€” **draft** (mutable, no version pinned) and +**locked** (immutable, tied to a frozen version). Scope selection rules +are documented under "Shared flag semantics" below. + +| 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 --from-file=./payload.json`
`dr pipeline input create --pipeline --version=2 ./payload.json --output json` | **Positional:** `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--from-file=`, `--output json`.
**Body sent to API:** `{"payload": }`. | +| `dr pipeline input list` | `GET /pipelines/{id}/inputs` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipeline input list --pipeline ` (draft)
`dr pipeline input list --pipeline --version=2` (locked)
`dr pipeline input list --pipeline --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output 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 `
`dr pipeline input get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipeline input update` | `PATCH /pipelines/{id}/inputs/{input_id}` (draft only) | `dr pipeline input update --pipeline ./payload.json`
`dr pipeline input update --pipeline --from-file=./payload.json --output json` | **Positional:** `` (required), `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--from-file=`, `--output json`.
**Body sent to API:** `{"payload": }`. | +| `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 `
`dr pipeline input delete --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + +## Runs (`dr pipeline run …`) + +Same draft/locked scope rules as inputs. The wire-level URLs still use +the legacy term `dispatches` / `dispatch_id`, but the CLI's `--output +json` remaps these to `run_id` / `covalent_run_id` so the JSON output +matches the rest of the CLI vocabulary. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline run create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipeline run create --pipeline --input `
`dr pipeline run create --pipeline --version=2 --input --output json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output json`.
**Body sent to API:** `{"input_id": ""}`. | +| `dr pipeline run list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipeline run list --pipeline `
`dr pipeline run list --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | +| `dr pipeline run get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipeline run get --pipeline `
`dr pipeline run get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipeline run status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` (locked) | `dr pipeline run status --pipeline `
`dr pipeline run status --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipeline run cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipeline run cancel --pipeline `
`dr pipeline run cancel --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + +## Schedules (`dr pipeline schedule …`) + +Schedules are **locked-only** β€” every verb requires both `--pipeline` and +`--version`. There is no draft scope or `--scope` flag. + +| 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 `
`dr pipeline schedule create --pipeline --version=2 --cron "0 9 * * *" --input --timezone America/Los_Angeles`
`… --output json` | **Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--cron ""` (required), `--input ` (required), `--timezone ` (default `UTC`), `--output json`.
**Body sent to API:** `{"cron_expression": "...", "pipeline_input_id": "...", "timezone": "..."}`. | +| `dr pipeline schedule list` | `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule list --pipeline --version=2`
`dr pipeline schedule list --pipeline --version=2 --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--offset `, `--limit `, `--output json`. | +| `dr pipeline schedule get` | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule get --pipeline --version=2 `
`… --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--output json`. | +| `dr pipeline schedule update` | `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule update --pipeline --version=2 --cron "*/15 * * * *"`
`dr pipeline schedule update --pipeline --version=2 --timezone Europe/Berlin`
`… --cron "0 0 * * *" --timezone UTC --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--cron ""`, `--timezone `, `--output json`. At least one of `--cron`/`--timezone` must be supplied.
**Body sent to API:** `{"cron_expression"?: "...", "timezone"?: "..."}` (only fields you changed). | +| `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, > 0). | + +--- + +## Execution environments (`dr pipeline environment …`) + +Pipeline execution environments are named, immutable-versioned bags of +pip packages. They live at the top of the pipelines namespace (not +nested under a specific pipeline) and are created/updated independently. +Each `update` appends a new version; older versions can be deleted +individually. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline environment create` | `POST /pipelines/environments` | `dr pipeline environment create --name ml-base --package numpy --package pandas==2.0`
`dr pipeline environment create --name ml-base --package "numpy,pandas==2.0" --description "training base" --output json` | **Flags:** `--name ` (required), `--description `, `--package ` (required, repeatable, also accepts comma-separated values), `--output json`.
**Body sent to API:** `{"name": "...", "description"?: "...", "packages": ["..."]}`. | +| `dr pipeline environment list` | `GET /pipelines/environments` | `dr pipeline environment list`
`dr pipeline environment list --offset 50 --limit 10 --output json` | **Flags:** `--offset `, `--limit `, `--output json`. | +| `dr pipeline environment update` | `PATCH /pipelines/environments/{environment_id}` | `dr pipeline environment update --package scikit-learn`
`dr pipeline environment update --package "scikit-learn,torch" --output json` | **Positional:** `` (required).
**Flags:** `--package ` (required, repeatable, also accepts comma-separated values), `--output json`.
**Body sent to API:** `{"packages": ["..."]}`. | +| `dr pipeline environment delete` | `DELETE /pipelines/environments/{environment_id}` | `dr pipeline environment delete ` | **Positional:** `` (required). | +| `dr pipeline environment version delete` | `DELETE /pipelines/environments/{environment_id}/versions/{version_id}` | `dr pipeline environment version delete --environment 2` | **Positional:** `` (positive integer, required).
**Flags:** `--environment ` (required). | + +> [!NOTE] +> The pipelines-api currently does not expose `GET` endpoints for a +> single environment or for individual versions, so the CLI does not +> ship `environment get` or `environment version get`. The full version +> history is only returned in the `create` and `update` responses. + +--- + +## Shared flag semantics + +### `--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 +version β€” through a pair of optional flags: + +| Flags supplied | Resolved scope | URL used | +|---|---|---| +| _(none)_ | `draft` | `/pipelines/{id}/…` | +| `--version=N` | `locked` (auto) | `/pipelines/{id}/versions/N/…` | +| `--scope=draft` | `draft` | `/pipelines/{id}/…` | +| `--scope=locked --version=N` | `locked` | `/pipelines/{id}/versions/N/…` | +| `--scope=draft --version=N` | **error** | `--scope=draft cannot be combined with --version` | +| `--scope=locked` (no `--version`) | **error** | `--scope=locked requires --version=` | +| `--scope=garbage` | **error** | `invalid --scope: "garbage" (supported: draft, locked)` | + +### `--from-file` / positional file (create + update verbs) + +`pipelines create`, `pipelines update`, `pipelines input create`, and +`pipelines input update` all accept the input file in two equivalent +ways: + +```bash +dr pipeline create ./my_pipeline.py +dr pipeline create --from-file=./my_pipeline.py +``` + +Exactly one of the two must be supplied; passing both yields +`specify the file either as a positional argument or via --from-file, not both`, +and supplying neither yields `a file path is required …` (or +`a JSON payload file is required …` for input verbs). + +### `--output` + +Every read/write verb that produces a payload accepts `--output json` to +emit the underlying response struct as indented JSON. Any other value +(e.g. `--output yaml`, `--output csv`) is rejected with +`invalid output format: (supported: json)`. + +### `auth` / `--skip-auth` + +All verbs run `auth.EnsureAuthenticatedE` as their `PreRunE`. Pass the +global `--skip-auth` flag (or set `DATAROBOT_CLI_SKIP_AUTH=true`) when +exercising a local API stub that doesn't implement `/version/`. + +--- + +## Quick endpoint lookup + +| API endpoint | CLI command | +|---|---| +| `POST /pipelines` | `dr pipeline create` | +| `GET /pipelines` | `dr pipeline list` | +| `GET /pipelines/{id}` | `dr pipeline get` | +| `PATCH /pipelines/{id}` | `dr pipeline update` | +| `DELETE /pipelines/{id}` | `dr pipeline delete` | +| `PATCH /pipelines/{id}/mode` | `dr pipeline lock` | +| `GET /pipelines/{id}/versions` | `dr pipeline version list` | +| `GET /pipelines/{id}/versions/{ver}` | `dr pipeline version get` | +| `GET /pipelines/{id}/graph` | `dr pipeline graph` (draft) | +| `GET /pipelines/{id}/versions/{ver}/graph` | `dr pipeline graph --version=N` | +| `POST /pipelines/{id}/inputs` | `dr pipeline input create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input create --version=N` | +| `GET /pipelines/{id}/inputs` | `dr pipeline input list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input list --version=N` | +| `GET /pipelines/{id}/inputs/{input_id}` | `dr pipeline input get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input get --version=N` | +| `PATCH /pipelines/{id}/inputs/{input_id}` | `dr pipeline input update` | +| `DELETE /pipelines/{id}/inputs/{input_id}` | `dr pipeline input delete` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input delete --version=N` | +| `POST /pipelines/{id}/dispatches` | `dr pipeline run create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/dispatches` | `dr pipeline run create --version=N` | +| `GET /pipelines/{id}/dispatches` | `dr pipeline run list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches` | `dr pipeline run list --version=N` | +| `GET /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipeline run get --version=N` | +| `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipeline run status` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` | `dr pipeline run status --version=N` | +| `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run cancel` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipeline run cancel --version=N` | +| `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/{schedule_id}` | `dr pipeline schedule get` | +| `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule update` | +| `DELETE /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule delete` | +| `POST /pipelines/environments` | `dr pipeline environment create` | +| `GET /pipelines/environments` | `dr pipeline environment list` | +| `PATCH /pipelines/environments/{environment_id}` | `dr pipeline environment update` | +| `DELETE /pipelines/environments/{environment_id}` | `dr pipeline environment delete` | +| `DELETE /pipelines/environments/{environment_id}/versions/{version_id}` | `dr pipeline environment version delete` | diff --git a/docs/commands/pipeline.md b/docs/commands/pipeline.md new file mode 100644 index 000000000..eedc67322 --- /dev/null +++ b/docs/commands/pipeline.md @@ -0,0 +1,584 @@ +# `dr pipeline` - Pipelines API management + +Manage AI/ML pipelines orchestrated by Covalent through the DataRobot +pipelines service. The `dr pipeline` group is a thin CLI wrapper over +the pipelines REST API: every subcommand maps directly to a single +endpoint. + +## Quick start + +```bash +# List pipelines registered with the pipelines service +dr pipeline list + +# Register a new draft pipeline by uploading a DataRobot pipeline source file +dr pipeline create ./my_pipeline.py --description "First draft" + +# Append a new version after editing the file +dr pipeline update ./my_pipeline.py + +# Promote the draft to locked when you are happy with it +dr pipeline lock + +# Cancel a stuck run +dr pipeline run cancel --pipeline +``` + +> [!NOTE] +> The `pipeline` command is currently behind a feature gate. Enable it +> by exporting `DATAROBOT_CLI_FEATURE_PIPELINE=true` before running any +> `dr pipeline` subcommand. See +> [Feature gates](../development/feature-gates.md) for details. + +> [!NOTE] +> **First time?** If you're new to the CLI, start with the +> [Quick start](../../README.md#quick-start) for step-by-step setup +> instructions. + +## Synopsis + +```bash +dr pipeline [subcommand] [flags] +``` + +## Description + +A **pipeline** is a versioned bundle of Python source defining a DataRobot pipeline (one or more tasks). Each +top-level `dr pipeline` subcommand operates on one of four resources: + +- the **pipeline** itself (create, list, get, update, delete, lock), +- pipeline **versions** (list, get, graph), +- pipeline **inputs** β€” JSON payloads supplied to a run, +- pipeline **runs** β€” concrete executions on Covalent, +- pipeline **schedules** β€” recurring runs on a cron expression, +- pipeline **environments** β€” named, immutable-versioned bags of pip + packages that pipelines can be built against. + +Versions are created automatically: + +- The first `create` call registers the source as **v1** in `draft` + mode. +- `update` re-uploads the same file (or an edited copy) and appends + **v2**, **v3**, etc., as long as the pipeline name still matches and + the pipeline is still in `draft` mode. +- `lock` promotes a draft to **locked** mode. Locked pipelines are + immutable; their inputs and schedules become valid. + +Inputs, runs, and the graph endpoint exist in two scopes β€” +**draft** (mutable, no version pinned) and **locked** (immutable, tied +to a frozen version) β€” selected via the shared `--scope` and +`--version` flags. Schedules are locked-only. + +> For an exhaustive table mapping every CLI command to its API endpoint, +> see [pipeline-reference.md](pipeline-reference.md). + +## Command groups + +| Group | Endpoint(s) | Purpose | +|------------------------------|--------------------------------------------------|--------------------------------------------------------| +| `dr pipeline create` | `POST /api/v2/pipelines` | Upload a Python file to register a new pipeline. | +| `dr pipeline list` | `GET /api/v2/pipelines` | Paginated list with mode filtering. | +| `dr pipeline get` | `GET /api/v2/pipelines/{id}` | Pipeline detail including all versions. | +| `dr pipeline update` | `PATCH /api/v2/pipelines/{id}` | Re-upload a file to append a new version. | +| `dr pipeline delete` | `DELETE /api/v2/pipelines/{id}` | Remove a pipeline and all of its versions. | +| `dr pipeline lock` | `PATCH /api/v2/pipelines/{id}/mode` | Promote a draft to locked mode. | +| `dr pipeline version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | +| `dr pipeline graph` | `…/graph` (draft) or `…/versions/{ver}/graph` | Render the pipeline/task DAG. | +| `dr pipeline input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for runs. | +| `dr pipeline run …` | `…/dispatches` and `…/dispatches/{dispatch_id}` | Trigger, inspect, and cancel runs. | +| `dr pipeline schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) runs on locked versions. | +| `dr pipeline environment …` | `/api/v2/pipelines/environments[/...]` | Manage named, versioned pip-package execution environments. | + +## Subcommands + +### `create` + +Upload a Python file defining a DataRobot pipeline (one or more tasks) and +register a new pipeline. The pipeline name is extracted from the file and used as the +pipeline name. + +```bash +dr pipeline create [flags] +dr pipeline create --from-file= [flags] +``` + +**Arguments:** + +- `` β€” path to a `.py` file containing a single DataRobot pipeline. + Mutually exclusive with `--from-file`. + +**Flags:** + +- `--from-file ` β€” alternative to the positional file argument. +- `--description ` β€” optional human-readable description stored on + the pipeline. +- `--mode ` β€” pipeline lifecycle mode. Defaults to `draft`. +- `--output ` β€” emit machine-parseable JSON instead of the + human-readable summary. + +**Example:** + +```bash +$ dr pipeline create ./confluence_to_vdb.py --description "test" +Pipeline ID: 683c2a1b4f8e1a2b3c4d5e6f +Name: confluence_to_vdb +Version: 1 +Status: READY +Mode: draft +Tasks: create_vector_database, ingest_confluence_files, setup_credential_and_datastore +Created: 2026-04-28T11:42:28Z +``` + +### `list` + +List pipelines registered with the pipelines service, with optional +mode filtering and pagination. + +```bash +dr pipeline list [flags] +``` + +**Flags:** + +- `--mode ` β€” filter by pipeline mode. +- `--offset ` β€” pagination offset. Default `0`. +- `--limit ` β€” pagination limit (1-200). Default `50`. +- `--output ` β€” emit machine-parseable JSON instead of a table. + +**Example:** + +```bash +$ dr pipeline list +Showing 1 of 1 (offset=0 limit=50) + +ID NAME MODE ACTIVE VERSION UPDATED +683c2a1b4f8e1a2b3c4d5e6f confluence_to_vdb draft true v3 2026-04-28T12:25:11Z +``` + +| Column | Meaning | +|-----------|-----------------------------------------------------------------| +| `ID` | Pipeline ObjectId, used as the argument to `get` / `update` / etc. | +| `NAME` | Pipeline name extracted from the originally uploaded file. | +| `MODE` | `draft` (mutable) or `locked` (immutable). | +| `ACTIVE` | `true` while the pipeline has not been soft-deleted. | +| `VERSION` | Latest version number, or `β€”` when no versions exist yet. | +| `UPDATED` | Last modification time in UTC (RFC 3339). | + +### `get` + +Display full details of a single pipeline including all versions. + +```bash +dr pipeline get [flags] +``` + +**Arguments:** + +- `` β€” the ObjectId returned by `create` / shown in `pipeline list`. + +**Flags:** + +- `--output ` β€” emit machine-parseable JSON. + +**Example:** + +```bash +$ dr pipeline get 683c2a1b4f8e1a2b3c4d5e6f +ID: 683c2a1b4f8e1a2b3c4d5e6f +Name: confluence_to_vdb +Description: test +Mode: draft +Active: true +Created: 2026-04-28T11:42:28Z +Updated: 2026-04-28T12:25:11Z + +Versions (3): + VERSION STATUS PYTHON CREATED TASKS + v1 READY 3.12 2026-04-28T11:42:28Z create_vector_database, ingest_confluence_files, setup_credential_and_datastore + v2 READY 3.12 2026-04-28T12:24:54Z create_vector_database, ingest_confluence_files, setup_credential_and_datastore + v3 READY 3.12 2026-04-28T12:25:11Z create_vector_database, ingest_confluence_files, setup_credential_and_datastore +``` + +If a version failed to register, its `error_detail` is rendered as a +dim line underneath the table. + +If the pipeline doesn't exist, `get` prints +`No pipeline found with id: ` and exits 0 instead of dumping an +HTTP error. + +### `update` + +Re-upload a Python file to update a draft pipeline. A new version is +appended. + +```bash +dr pipeline update [flags] +dr pipeline update --from-file= [flags] +``` + +**Arguments:** + +- `` β€” the ObjectId of the pipeline to update. +- `` β€” path to the updated `.py` file. Mutually exclusive with + `--from-file`. + +**Flags:** + +- `--from-file ` β€” alternative to the positional file argument. +- `--output ` β€” emit machine-parseable JSON. + +**Constraints:** + +- The pipeline name encoded in the uploaded file **must match** the pipeline's + existing name. To register a different pipeline, use `create` instead. +- Locked pipelines cannot be updated. The API responds with + `409 Conflict`. + +### `delete` + +Delete a pipeline and all of its versions. + +```bash +dr pipeline delete +``` + +**Arguments:** + +- `` β€” the ObjectId of the pipeline to delete. + +**Example:** + +```bash +$ dr pipeline delete 683c2a1b4f8e1a2b3c4d5e6f +Deleted pipeline: 683c2a1b4f8e1a2b3c4d5e6f +``` + +If the pipeline doesn't exist, `delete` prints +`No pipeline found with id: ` and exits 0. + +### `lock` + +Promote a draft pipeline to locked mode. Once locked, the pipeline can +no longer be updated and locked runs/inputs/schedules become +valid. + +```bash +dr pipeline lock [flags] +``` + +**Arguments:** + +- `` β€” the ObjectId of the pipeline to lock. + +**Flags:** + +- `--output ` β€” emit machine-parseable JSON. + +**Example:** + +```bash +$ dr pipeline lock 683c2a1b4f8e1a2b3c4d5e6f +Pipeline ID: 683c2a1b4f8e1a2b3c4d5e6f +Name: confluence_to_vdb +Mode: locked +Version: v3 +Status: READY +Tasks: create_vector_database, ingest_confluence_files, setup_credential_and_datastore +Locked: 2026-04-28T12:30:00Z +``` + +### `version` + +Read-only access to pipeline versions. + +```bash +dr pipeline version list --pipeline [--offset N] [--limit N] [--output json] +dr pipeline version get --pipeline [--output json] +``` + +`version list` returns the same data that's shown inline by +`pipeline get`, but in a paginated stand-alone view. `version get` +shows a single version in detail (pipeline, tasks, Python version, +creation timestamp, and any error detail). + +### `graph` + +Display the pipeline/task DAG for a pipeline as either a JSON +payload (for visualisation tooling) or a human-readable summary. + +```bash +dr pipeline graph --pipeline # draft graph +dr pipeline graph --pipeline --version=N # locked-version graph +dr pipeline graph --pipeline --scope=draft # explicit draft +dr pipeline graph --pipeline --scope=locked --version=N +dr pipeline graph --pipeline --output json # JSON payload +``` + +The human view prints the pipeline header followed by `Nodes (N):` and +`Edges (M):` tables. Pass `--output json` to get the structured `Graph` +object (`lattice`, `nodes[]`, `edges[]` β€” JSON keys preserved while the API +wire format is unchanged) suitable for piping to +visualisation tooling. + +See [Shared `--scope` / `--version` semantics](#scope--version-flags) +below for the full flag truth table. + +### `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. +- All verbs accept `--scope` / `--version` (see below). Inputs in the + `locked` scope are immutable, so `input update` is draft-only. + +### `run` + +Trigger, inspect, and cancel pipeline executions. + +```bash +dr pipeline run create --pipeline --input # draft +dr pipeline run create --pipeline --version=N --input # locked +dr pipeline run list --pipeline [--scope|--version] +dr pipeline run get --pipeline [--scope|--version] +dr pipeline run status --pipeline [--scope|--version] +dr pipeline run cancel --pipeline [--scope|--version] +``` + +`run status` is a lighter-weight call than `run get` β€” +intended for polling β€” and returns just the run ID, status, and +the corresponding Covalent dispatch ID. + +`run cancel` returns `409 Conflict` if the run is already in +a terminal state (COMPLETED / FAILED / CANCELLED). + +### `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 * * * *"] [--timezone Europe/Berlin] +dr pipeline schedule delete --pipeline --version=N +``` + +`schedule update` requires at least one of `--cron` or `--timezone`. + +### `environment` + +Manage pipeline execution environments β€” named, immutable-versioned +bags of pip packages that pipelines can be built against. Environments +live at the top of the pipelines namespace (not nested under a specific +pipeline) and have their own lifecycle. + +```bash +dr pipeline environment create --name --package [--package ] ... +dr pipeline environment list [--offset N] [--limit N] [--output json] +dr pipeline environment update --package [...] +dr pipeline environment delete +dr pipeline environment version delete --environment +``` + +`create` registers a new environment with an initial v1 containing the +supplied pip packages; the returned record reports the build status of +that first version. `update` adds packages to an existing environment +by creating a new immutable version (older versions are unchanged). +`delete` soft-deletes the most recent active version (and cascades the +parent if no versions remain). `version delete` targets a specific +older version without touching the parent. + +`--package` is repeatable and also accepts comma-separated values: + +```bash +dr pipeline environment create --name ml-base \ + --package numpy --package pandas==2.0 +dr pipeline environment create --name ml-base \ + --package "numpy,pandas==2.0,scikit-learn" +``` + +> [!NOTE] +> The pipelines-api currently does not surface `GET` endpoints for a +> single environment or for the version list. The full version history +> is only returned in the `create` and `update` responses. + +## Shared flags + +### `--scope` / `--version` flags + +Inputs, runs, and `graph` mirror the API's two URL shapes β€” +`/pipelines/{id}/…` for the mutable draft and +`/pipelines/{id}/versions/{ver}/…` for a locked version β€” through a +pair of optional flags: + +| Flags supplied | Resolved scope | URL used | +|--------------------------------------|-------------------|-------------------------------------| +| _(none)_ | `draft` | `/pipelines/{id}/…` | +| `--version=N` | `locked` (auto) | `/pipelines/{id}/versions/N/…` | +| `--scope=draft` | `draft` | `/pipelines/{id}/…` | +| `--scope=locked --version=N` | `locked` | `/pipelines/{id}/versions/N/…` | +| `--scope=draft --version=N` | **error** | `--scope=draft cannot be combined with --version` | +| `--scope=locked` (no `--version`) | **error** | `--scope=locked requires --version=` | +| `--scope=garbage` | **error** | `invalid --scope: "garbage" (supported: draft, locked)` | + +Schedules do not accept `--scope`; they are locked-only and require +`--version` on every verb. + +### `--from-file` / positional file + +`pipeline create`, `pipeline update`, `pipeline input create`, and +`pipeline input update` all accept the input file in two equivalent +ways: + +```bash +dr pipeline create ./my_pipeline.py +dr pipeline create --from-file=./my_pipeline.py +``` + +Exactly one of the two must be supplied; passing both yields +`specify the file either as a positional argument or via --from-file, not both`, +and supplying neither yields `a file path is required …` (or +`a JSON payload file is required …` for input verbs). + +### `--output` + +Every read/write verb that produces a payload accepts `--output json` +to emit the underlying response struct as indented JSON. Any other +value is rejected with +`invalid output format: (supported: json)`. + +```bash +dr pipeline list --output json | jq '.items[].pipeline_id' +``` + +### Global options + +All [global flags](README.md#global-flags) are available, notably +`--debug` for protocol-level tracing and `--skip-auth` for advanced +scenarios. + +## Local development + +While iterating against a locally running pipelines-api (default port +`8100`), point the CLI at `http://localhost:8100` and bypass token +verification using the prefixed environment variables: + +```bash +export DATAROBOT_CLI_FEATURE_PIPELINE=true +export DATAROBOT_CLI_ENDPOINT=http://localhost:8100/api/v2 +export DATAROBOT_CLI_TOKEN=local +export DATAROBOT_CLI_SKIP_AUTH=true + +./dist/dr pipeline list +``` + +Why each variable matters: + +| Variable | Purpose | +|-----------------------------------|----------------------------------------------------------------------------------------------------------| +| `DATAROBOT_CLI_FEATURE_PIPELINE` | Reveals the feature-gated `pipeline` command group. | +| `DATAROBOT_CLI_ENDPOINT` | Auto-bound to viper's `endpoint` key via `SetEnvPrefix("DATAROBOT_CLI") + AutomaticEnv()` in the CLI. | +| `DATAROBOT_CLI_TOKEN` | Same prefix story β€” bound to viper's `token` key for outbound `Authorization: Bearer` headers. | +| `DATAROBOT_CLI_SKIP_AUTH` | Skips token verification against `/version/`, which the local stub does not implement. | + +The unprefixed `DATAROBOT_ENDPOINT` / `DATAROBOT_API_TOKEN` variables +are **only** bound to viper after a successful token verification and +therefore do not work alongside `--skip-auth` or +`DATAROBOT_CLI_SKIP_AUTH=true`. Always prefer the `DATAROBOT_CLI_*` +names during local development. + +> [!TIP] +> Use `--debug` to see the full request URL, headers, and body the CLI +> sends. Logs are written to `.dr-tui-debug.log`. + +For a step-by-step walkthrough of how `dr pipeline list` was wired up, +see [Adding a command](../development/adding-a-command.md). + +## Examples + +### Pipeline lifecycle + +```bash +# Register a draft, append a version, lock it, then delete it +dr pipeline create ./my_pipeline.py --description "Initial draft" +dr pipeline update ./my_pipeline.py +dr pipeline lock +dr pipeline delete +``` + +### Inspect versions and graph + +```bash +dr pipeline version list --pipeline +dr pipeline version get --pipeline 2 +dr pipeline graph --pipeline --version=2 --output json +``` + +### Trigger a run + +```bash +# 1. Register a JSON input on the draft scope +dr pipeline input create --pipeline ./input.json + +# 2. Trigger a run with that input +dr pipeline run create --pipeline --input + +# 3. Poll status until it reaches a terminal state +dr pipeline run status --pipeline +``` + +### Schedule a recurring run on a locked version + +```bash +dr pipeline schedule create \ + --pipeline --version=2 \ + --cron "0 */6 * * *" --input --timezone America/Los_Angeles +``` + +### Scripting friendliness + +```bash +# All UUIDs of locked pipelines, one per line +dr pipeline list --mode locked --output json | jq -r '.items[].pipeline_id' +``` + +## Error handling + +The CLI surfaces backend errors verbatim and exits non-zero. The most +common status codes you will see: + +| Status | Cause | +|--------|------------------------------------------------------------------------------------| +| `400` | Invalid Python file, mismatched pipeline name, or malformed JSON payload. | +| `404` | The provided `` / version / input / run / schedule does not exist. | +| `409` | Tried to update a `locked` pipeline, or cancel an already-terminal run. | + +For most `get` / `delete` verbs the CLI translates a 404 into a +friendly informational line (e.g. `No pipeline found with id: …`) and +exits 0, so a no-op delete won't dump usage at the user. + +## See also + +- [Pipelines reference](pipeline-reference.md) β€” exhaustive table + mapping every CLI command to its API endpoint, usage variants, and + inputs. +- [Authentication](auth.md) β€” how `dr auth login` and `--skip-auth` + interact. +- [Configuration](../user-guide/configuration.md) β€” config file and + environment-variable precedence. +- [Adding a command](../development/adding-a-command.md) β€” how the + pipelines verbs were built. +- [Feature gates](../development/feature-gates.md) β€” flipping + `DATAROBOT_CLI_FEATURE_PIPELINE` on and off. diff --git a/docs/commands/start.md b/docs/commands/start.md index c1272d743..6b770ba78 100644 --- a/docs/commands/start.md +++ b/docs/commands/start.md @@ -316,7 +316,7 @@ If a quickstart script fails, the error is displayed and the command exits. Chec - **First-time setup**—initializing a newly cloned template or starting from scratch. - **Quick restart**—restarting development after a break. - **Onboarding**—helping new team members get started quickly. -- **CI/CD**—automating application initialization in pipelines. +- **CI/CD**—automating application initialization in pipeline. - **General entry point**—universal command that works whether you have a template or not. ### ❌ When not to use diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fef2e1c5d..52c1284c1 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -70,6 +70,8 @@ nav: - completion: commands/completion.md - self: commands/self.md - plugins: commands/plugins.md + - pipeline: commands/pipeline.md + - pipeline reference: commands/pipeline-reference.md - component: commands/component-managed-updates.md - Development: - development/README.md diff --git a/go.mod b/go.mod index c1caaf9bd..3b6a1baed 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/ulikunitz/xz v0.5.15 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 4c64e45d3..32cf674c1 100644 --- a/go.sum +++ b/go.sum @@ -188,8 +188,8 @@ golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= diff --git a/internal/drapi/auth.go b/internal/drapi/auth.go index cd5d11a80..f85e35aaa 100644 --- a/internal/drapi/auth.go +++ b/internal/drapi/auth.go @@ -15,30 +15,22 @@ package drapi import ( - "context" "net/http" "github.com/datarobot/cli/internal/config" ) -// SetAuthHeaders populates Authorization, User-Agent, and (when enabled) -// X-DataRobot-Api-Consumer-Trace on req using the same sources the verb -// helpers use inline. Exposed so callers that build their own *http.Request -// (e.g. multipart streaming uploads in drapi/filesapi) can reuse the -// canonical drapi auth-injection logic instead of re-implementing it. -// -// Reuses the package-level `token` memoization declared in get.go. -func SetAuthHeaders(req *http.Request) error { - if token == "" { - var err error - - token, err = config.GetAPIKey(context.Background()) - if err != nil { - return err - } +// AuthorizeRequest sets the standard DataRobot API headers on req: +// Authorization (Bearer token), User-Agent, and the optional +// X-DataRobot-Api-Consumer-Trace. The request body is never read, so this +// is safe to call on multipart upload requests. +func AuthorizeRequest(req *http.Request) error { + bearer, err := getToken() + if err != nil { + return err } - req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Authorization", "Bearer "+bearer) req.Header.Set("User-Agent", config.GetUserAgentHeader()) if config.IsAPIConsumerTrackingEnabled() { diff --git a/internal/drapi/auth_test.go b/internal/drapi/auth_test.go index 1bae1f805..32d2bcd6e 100644 --- a/internal/drapi/auth_test.go +++ b/internal/drapi/auth_test.go @@ -24,19 +24,19 @@ import ( "github.com/stretchr/testify/require" ) -func TestSetAuthHeaders_AuthorizationAndUserAgent(t *testing.T) { +func TestAuthorizeRequest_AuthorizationAndUserAgent(t *testing.T) { defer resetTokenForTest(t, "test-token")() req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - require.NoError(t, SetAuthHeaders(req)) + require.NoError(t, AuthorizeRequest(req)) assert.Equal(t, "Bearer test-token", req.Header.Get("Authorization")) assert.NotEmpty(t, req.Header.Get("User-Agent")) } -func TestSetAuthHeaders_TraceHeaderEnabled(t *testing.T) { +func TestAuthorizeRequest_TraceHeaderEnabled(t *testing.T) { defer resetTokenForTest(t, "test-token")() viperx.Reset() @@ -46,12 +46,12 @@ func TestSetAuthHeaders_TraceHeaderEnabled(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - require.NoError(t, SetAuthHeaders(req)) + require.NoError(t, AuthorizeRequest(req)) assert.NotEmpty(t, req.Header.Get("X-DataRobot-Api-Consumer-Trace")) } -func TestSetAuthHeaders_TraceHeaderDisabled(t *testing.T) { +func TestAuthorizeRequest_TraceHeaderDisabled(t *testing.T) { defer resetTokenForTest(t, "test-token")() viperx.Reset() @@ -61,31 +61,31 @@ func TestSetAuthHeaders_TraceHeaderDisabled(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - require.NoError(t, SetAuthHeaders(req)) + require.NoError(t, AuthorizeRequest(req)) assert.Empty(t, req.Header.Get("X-DataRobot-Api-Consumer-Trace")) } -// TestSetAuthHeaders_MemoizesToken confirms a seeded token short-circuits -// config.GetAPIKey on subsequent calls β€” the second SetAuthHeaders call +// TestAuthorizeRequest_MemoizesToken confirms a seeded token short-circuits +// config.GetAPIKey on subsequent calls β€” the second AuthorizeRequest call // must succeed without contacting config (which would fail in this test env). -func TestSetAuthHeaders_MemoizesToken(t *testing.T) { +func TestAuthorizeRequest_MemoizesToken(t *testing.T) { defer resetTokenForTest(t, "test-token")() for range 2 { req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - require.NoError(t, SetAuthHeaders(req)) + require.NoError(t, AuthorizeRequest(req)) assert.Equal(t, "Bearer test-token", req.Header.Get("Authorization")) } } -// TestSetAuthHeaders_PropagatesTokenError confirms that when the token is +// TestAuthorizeRequest_PropagatesTokenError confirms that when the token is // unset and config.GetAPIKey fails (no DR_API_TOKEN, no config file in the // test env), the error is returned rather than silently producing a request // with an empty bearer. -func TestSetAuthHeaders_PropagatesTokenError(t *testing.T) { +func TestAuthorizeRequest_PropagatesTokenError(t *testing.T) { defer resetTokenForTest(t, "")() viperx.Reset() @@ -94,6 +94,6 @@ func TestSetAuthHeaders_PropagatesTokenError(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - err = SetAuthHeaders(req) + err = AuthorizeRequest(req) require.Error(t, err) } diff --git a/internal/drapi/client.go b/internal/drapi/client.go new file mode 100644 index 000000000..6f912314e --- /dev/null +++ b/internal/drapi/client.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. + +// This file owns the HTTP client constructor, the cached token resolver, and +// the DefaultClientTimeout constant shared by every verb helper (get.go, +// post.go, patch.go, delete.go). AuthorizeRequest lives in auth.go. +// +// HTTPError, the package-level `token` cache, and resolveToken() live in +// get.go for historical reasons and are reused from this file. + +package drapi + +import ( + "net/http" + "time" +) + +// DefaultClientTimeout is the read/write timeout used by NewHTTPClient when +// callers don't specify their own. +const DefaultClientTimeout = 30 * time.Second + +// getToken returns the memoized API token, resolving and caching it on first +// use to avoid repeated VerifyToken() round-trips. The underlying `token` +// variable and resolveToken() function are defined in get.go. +func getToken() (string, error) { + if token != "" { + return token, nil + } + + resolved, err := resolveToken() + if err != nil { + return "", err + } + + token = resolved + + return token, nil +} + +// NewHTTPClient returns an *http.Client preconfigured with the given timeout. +// Use this in place of constructing &http.Client{...} inline so timeouts and +// future shared-transport tweaks live in one place. +func NewHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout} +} diff --git a/internal/drapi/client_test.go b/internal/drapi/client_test.go new file mode 100644 index 000000000..d6b317a62 --- /dev/null +++ b/internal/drapi/client_test.go @@ -0,0 +1,122 @@ +// 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 drapi + +import ( + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetTokenCache clears the package-level token cache so tests don't leak +// state into each other. The `token` variable is defined in get.go. +func resetTokenCache(t *testing.T) { + t.Helper() + + prev := token + + token = "" + + t.Cleanup(func() { + token = prev + }) +} + +// withSkipAuth installs a deterministic token in viper and turns on +// --skip-auth so resolveToken returns immediately without hitting the +// network. Returns the token that was installed for assertion. +func withSkipAuth(t *testing.T, value string) string { + t.Helper() + + prevSkip := viperx.GetBool("skip_auth") + prevKey := viperx.GetString(config.DataRobotAPIKey) + + viperx.Set("skip_auth", true) + viperx.Set(config.DataRobotAPIKey, value) + + t.Cleanup(func() { + viperx.Set("skip_auth", prevSkip) + viperx.Set(config.DataRobotAPIKey, prevKey) + }) + + return value +} + +func TestNewHTTPClient(t *testing.T) { + c := NewHTTPClient(7 * time.Second) + require.NotNil(t, c) + assert.Equal(t, 7*time.Second, c.Timeout) +} + +func TestDefaultClientTimeout(t *testing.T) { + // Spot-check the constant β€” guards against accidental changes that + // would slow down or speed up every API call. + assert.Equal(t, 30*time.Second, DefaultClientTimeout) +} + +func TestGetToken_ResolvesAndMemoizes(t *testing.T) { + resetTokenCache(t) + withSkipAuth(t, "abc123") + + got, err := getToken() + require.NoError(t, err) + assert.Equal(t, "abc123", got) + + // Mutating viper after the cache is populated should NOT change what + // getToken returns β€” the value is memoized for the lifetime of the + // process. + viperx.Set(config.DataRobotAPIKey, "different") + + cached, err := getToken() + require.NoError(t, err) + assert.Equal(t, "abc123", cached) +} + +func TestAuthorizeRequest_SetsExpectedHeaders(t *testing.T) { + resetTokenCache(t) + withSkipAuth(t, "shhh") + + req, err := http.NewRequest(http.MethodGet, "http://example/api/v2/foo", nil) + require.NoError(t, err) + + require.NoError(t, AuthorizeRequest(req)) + + assert.Equal(t, "Bearer shhh", req.Header.Get("Authorization")) + assert.NotEmpty(t, req.Header.Get("User-Agent")) +} + +func TestAuthorizeRequest_DoesNotConsumeBody(t *testing.T) { + resetTokenCache(t) + withSkipAuth(t, "shhh") + + req, err := http.NewRequest(http.MethodPost, "http://example/api/v2/foo", + io.NopCloser(strings.NewReader("payload"))) + require.NoError(t, err) + + require.NoError(t, AuthorizeRequest(req)) + + // Body must still be readable after AuthorizeRequest β€” this is the + // invariant that makes it safe for multipart uploads. + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, "payload", string(body)) +} diff --git a/internal/drapi/delete.go b/internal/drapi/delete.go index 09b4bddab..1ea5c4caf 100644 --- a/internal/drapi/delete.go +++ b/internal/drapi/delete.go @@ -16,25 +16,14 @@ package drapi import ( "bytes" - "context" "encoding/json" "net/http" - "time" "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/log" ) func Delete(url, info string, body any) (*http.Response, error) { - var err error - - if token == "" { - token, err = config.GetAPIKey(context.Background()) - if err != nil { - return nil, err - } - } - payload, err := json.Marshal(body) if err != nil { return nil, err @@ -45,14 +34,12 @@ func Delete(url, info string, body any) (*http.Response, error) { return nil, err } - req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("User-Agent", config.GetUserAgentHeader()) - req.Header.Add("Content-Type", "application/json") - - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Add("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + if err = AuthorizeRequest(req); err != nil { + return nil, err } + req.Header.Set("Content-Type", "application/json") + if info != "" { log.Infof("Deleting %s at: %s", info, url) } @@ -63,11 +50,7 @@ func Delete(url, info string, body any) (*http.Response, error) { return nil, err } - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(req) + resp, err := NewHTTPClient(DefaultClientTimeout).Do(req) if err != nil { return nil, err } diff --git a/internal/drapi/filesapi/fromfile.go b/internal/drapi/filesapi/fromfile.go index 6be037349..bfcd7cadf 100644 --- a/internal/drapi/filesapi/fromfile.go +++ b/internal/drapi/filesapi/fromfile.go @@ -115,7 +115,7 @@ func getAcceptingRedirect(requestURL string) (*http.Response, error) { return nil, fmt.Errorf("build status request: %w", err) } - if err := drapi.SetAuthHeaders(req); err != nil { + if err := drapi.AuthorizeRequest(req); err != nil { return nil, err } diff --git a/internal/drapi/filesapi/multipart.go b/internal/drapi/filesapi/multipart.go index 0e4b69996..904a1b098 100644 --- a/internal/drapi/filesapi/multipart.go +++ b/internal/drapi/filesapi/multipart.go @@ -69,7 +69,7 @@ func newStreamingMultipartRequest( req.ContentLength = int64(len(prologue)) + size + int64(len(epilogue)) } - if err := drapi.SetAuthHeaders(req); err != nil { + if err := drapi.AuthorizeRequest(req); err != nil { _ = pr.Close() return nil, err diff --git a/internal/drapi/get.go b/internal/drapi/get.go index f86cd4c4e..33ea4f417 100644 --- a/internal/drapi/get.go +++ b/internal/drapi/get.go @@ -22,6 +22,7 @@ import ( "time" "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" "github.com/datarobot/cli/internal/log" ) @@ -49,22 +50,22 @@ func SetToken(value string) { token = value } -const DefaultGetTimeoutSecs = 30 - -func Get(url, info string, timeoutSecs ...int) (*http.Response, error) { - timeout := DefaultGetTimeoutSecs - if len(timeoutSecs) > 0 { - timeout = timeoutSecs[0] +// resolveToken returns the API token used for outbound requests. +// When --skip-auth (or DATAROBOT_CLI_SKIP_AUTH) is active we trust whatever +// is in viper without contacting the server, so local development against +// stub APIs that don't implement /version/ still works. +func resolveToken() (string, error) { + if viperx.GetBool("skip_auth") { + return viperx.GetString(config.DataRobotAPIKey), nil } - var err error + return config.GetAPIKey(context.Background()) +} - // memoize token to avoid extra VerifyToken() calls - if token == "" { - token, err = config.GetAPIKey(context.Background()) - if err != nil { - return nil, err - } +func Get(url, info string, timeoutSecs ...int) (*http.Response, error) { + timeout := DefaultClientTimeout + if len(timeoutSecs) > 0 { + timeout = time.Duration(timeoutSecs[0]) * time.Second } req, err := http.NewRequest(http.MethodGet, url, nil) @@ -72,11 +73,8 @@ func Get(url, info string, timeoutSecs ...int) (*http.Response, error) { return nil, err } - req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("User-Agent", config.GetUserAgentHeader()) - - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Add("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + if err = AuthorizeRequest(req); err != nil { + return nil, err } if info != "" { @@ -85,11 +83,7 @@ func Get(url, info string, timeoutSecs ...int) (*http.Response, error) { log.Debug("Request Info: \n" + config.RedactedReqInfo(req)) - client := &http.Client{ - Timeout: time.Duration(timeout) * time.Second, - } - - resp, err := client.Do(req) + resp, err := NewHTTPClient(timeout).Do(req) if err != nil { return nil, err } @@ -109,12 +103,7 @@ func GetJSON(url, info string, v any, timeoutSecs ...int) error { return err } - err = json.NewDecoder(resp.Body).Decode(&v) - if err != nil { - return err - } - - resp.Body.Close() + defer resp.Body.Close() - return nil + return json.NewDecoder(resp.Body).Decode(&v) } diff --git a/internal/drapi/patch.go b/internal/drapi/patch.go index 78fe37862..b35160ade 100644 --- a/internal/drapi/patch.go +++ b/internal/drapi/patch.go @@ -16,25 +16,14 @@ package drapi import ( "bytes" - "context" "encoding/json" "net/http" - "time" "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/log" ) func Patch(url, info string, body any) (*http.Response, error) { - var err error - - if token == "" { - token, err = config.GetAPIKey(context.Background()) - if err != nil { - return nil, err - } - } - payload, err := json.Marshal(body) if err != nil { return nil, err @@ -45,14 +34,12 @@ func Patch(url, info string, body any) (*http.Response, error) { return nil, err } - req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("User-Agent", config.GetUserAgentHeader()) - req.Header.Add("Content-Type", "application/json") - - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Add("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + if err = AuthorizeRequest(req); err != nil { + return nil, err } + req.Header.Set("Content-Type", "application/json") + if info != "" { log.Infof("Updating %s at: %s", info, url) } @@ -63,11 +50,7 @@ func Patch(url, info string, body any) (*http.Response, error) { return nil, err } - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(req) + resp, err := NewHTTPClient(DefaultClientTimeout).Do(req) if err != nil { return nil, err } diff --git a/internal/drapi/post.go b/internal/drapi/post.go index eae3243c6..a40b02799 100644 --- a/internal/drapi/post.go +++ b/internal/drapi/post.go @@ -16,25 +16,14 @@ package drapi import ( "bytes" - "context" "encoding/json" "net/http" - "time" "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/log" ) func Post(url, info string, body any) (*http.Response, error) { - var err error - - if token == "" { - token, err = config.GetAPIKey(context.Background()) - if err != nil { - return nil, err - } - } - payload, err := json.Marshal(body) if err != nil { return nil, err @@ -45,14 +34,12 @@ func Post(url, info string, body any) (*http.Response, error) { return nil, err } - req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("User-Agent", config.GetUserAgentHeader()) - req.Header.Add("Content-Type", "application/json") - - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Add("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + if err = AuthorizeRequest(req); err != nil { + return nil, err } + req.Header.Set("Content-Type", "application/json") + if info != "" { log.Infof("Creating %s at: %s", info, url) } @@ -65,11 +52,7 @@ func Post(url, info string, body any) (*http.Response, error) { return nil, err } - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(req) + resp, err := NewHTTPClient(DefaultClientTimeout).Do(req) if err != nil { return nil, err } diff --git a/internal/pipeline/environment.go b/internal/pipeline/environment.go new file mode 100644 index 000000000..6c194b2df --- /dev/null +++ b/internal/pipeline/environment.go @@ -0,0 +1,197 @@ +// 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. + +// environment.go contains the typed client wrappers for the pipeline +// execution-environment endpoints described under the +// `pipeline-execution-environments` tag of the pipelines-api OpenAPI spec. +// +// Environments are named, immutable-versioned bags of pip packages that +// pipelines can be built against. They live at the top of the pipelines +// namespace (not nested under a specific pipeline) and have their own +// lifecycle: +// +// POST /api/v2/pipelines/environments +// GET /api/v2/pipelines/environments +// PATCH /api/v2/pipelines/environments/{id} (adds packages -> new version) +// DELETE /api/v2/pipelines/environments/{id} (soft-deletes latest version, cascades parent) +// DELETE /api/v2/pipelines/environments/{id}/versions/{n} (soft-deletes a specific version) + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" + "time" + + "github.com/datarobot/cli/internal/config" +) + +// EnvironmentStatus mirrors PipelineEnvironmentStatus in the API. +type EnvironmentStatus string + +const ( + EnvironmentStatusCreating EnvironmentStatus = "CREATING" + EnvironmentStatusReady EnvironmentStatus = "READY" + EnvironmentStatusError EnvironmentStatus = "ERROR" +) + +// EnvironmentVersion mirrors PipelineEnvironmentVersionResponse. +type EnvironmentVersion struct { + Version int `json:"version"` + Packages []string `json:"packages"` + Status EnvironmentStatus `json:"status"` + ErrorDetail *string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Environment mirrors PipelineEnvironmentResponse (full detail). +type Environment struct { + EnvironmentID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latestVersion"` + Versions []EnvironmentVersion `json:"versions"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// EnvironmentSummary mirrors PipelineEnvironmentSummaryResponse (list item). +type EnvironmentSummary struct { + EnvironmentID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latestVersion"` + LatestStatus EnvironmentStatus `json:"latestStatus"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// EnvironmentCreateRequest mirrors PipelineEnvironmentCreateRequest. +type EnvironmentCreateRequest struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Packages []string `json:"packages"` +} + +// EnvironmentUpdateRequest mirrors PipelineEnvironmentUpdateRequest. +type EnvironmentUpdateRequest struct { + Packages []string `json:"packages"` +} + +// CreateEnvironment POSTs a new environment with an initial set of pip +// packages. The API returns 201 with the full Environment payload (a +// single CREATING version is returned immediately; READY status is +// reached asynchronously by the covalent build). +func CreateEnvironment(name, description string, packages []string) (*Environment, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments") + if err != nil { + return nil, err + } + + body := EnvironmentCreateRequest{ + Name: name, + Packages: packages, + } + if description != "" { + body.Description = &description + } + + var result Environment + + err = doJSON(http.MethodPost, endpoint, body, "create environment", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListEnvironments returns a paginated slice of environments. The API +// returns a bare JSON array (no envelope), newest first. +func ListEnvironments(offset, limit int) ([]EnvironmentSummary, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments") + 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[EnvironmentSummary] + + err = doJSON(http.MethodGet, endpoint, nil, "environments", &page) + if err != nil { + return nil, err + } + + return page.Data, nil +} + +// UpdateEnvironment PATCHes an environment with additional packages, +// creating a new immutable version. The response includes the full +// Environment with all versions ordered newest-first. +func UpdateEnvironment(envID string, packages []string) (*Environment, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments/" + envID) + if err != nil { + return nil, err + } + + body := EnvironmentUpdateRequest{Packages: packages} + + var result Environment + + err = doJSON(http.MethodPatch, endpoint, body, "update environment", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteEnvironment soft-deletes the most-recent active version of an +// environment. If no active versions remain, the parent environment is +// soft-deleted as well. +func DeleteEnvironment(envID string) error { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments/" + envID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete environment") +} + +// DeleteEnvironmentVersion soft-deletes a specific version of an +// environment without touching the parent. +func DeleteEnvironmentVersion(envID string, version int) error { + endpoint, err := config.GetEndpointURL( + "/api/v2/pipelines/environments/" + envID + "/versions/" + strconv.Itoa(version), + ) + if err != nil { + return err + } + + return doDelete(endpoint, "delete environment version") +} diff --git a/internal/pipeline/environment_output.go b/internal/pipeline/environment_output.go new file mode 100644 index 000000000..3700498b1 --- /dev/null +++ b/internal/pipeline/environment_output.go @@ -0,0 +1,298 @@ +// 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. + +// environment_output.go centralises the human/JSON output rendering used by +// the `dr pipelines environment` verbs so each verb file stays focused on +// flag wiring. +package pipeline + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "slices" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// environmentVersionJSON is the DTO for a single EnvironmentVersion in JSON output. +type environmentVersionJSON struct { + Version int `json:"version"` + Packages []string `json:"packages"` + Status string `json:"status"` + ErrorDetail *string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// environmentJSON is the CLI-facing DTO for `--output-format json` of an Environment. +type environmentJSON struct { + EnvironmentID string `json:"environment_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latest_version"` + Versions []environmentVersionJSON `json:"versions"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// environmentSummaryJSON is the CLI-facing DTO for `--output-format json` of an EnvironmentSummary. +type environmentSummaryJSON struct { + EnvironmentID string `json:"environment_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latest_version"` + LatestStatus string `json:"latest_status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toEnvironmentJSON(env Environment) environmentJSON { + versions := make([]environmentVersionJSON, len(env.Versions)) + + for i, v := range env.Versions { + versions[i] = environmentVersionJSON{ + Version: v.Version, + Packages: v.Packages, + Status: string(v.Status), + ErrorDetail: v.ErrorDetail, + CreatedAt: v.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: v.UpdatedAt.UTC().Format(time.RFC3339), + } + } + + return environmentJSON{ + EnvironmentID: env.EnvironmentID, + Name: env.Name, + Description: env.Description, + LatestVersion: env.LatestVersion, + Versions: versions, + CreatedAt: env.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: env.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +func toEnvironmentSummaryJSON(env EnvironmentSummary) environmentSummaryJSON { + return environmentSummaryJSON{ + EnvironmentID: env.EnvironmentID, + Name: env.Name, + Description: env.Description, + LatestVersion: env.LatestVersion, + LatestStatus: string(env.LatestStatus), + CreatedAt: env.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: env.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderEnvironment routes a single environment to JSON or human output. +func RenderEnvironment(format OutputFormat, env Environment) error { + if format == OutputFormatJSON { + return PrintEnvironmentJSON(env) + } + + PrintEnvironmentHuman(env) + + return nil +} + +// RenderEnvironments routes a list of environments to JSON or human output. +func RenderEnvironments(format OutputFormat, items []EnvironmentSummary) error { + if format == OutputFormatJSON { + return PrintEnvironmentListJSON(items) + } + + PrintEnvironmentListHuman(items) + + return nil +} + +// PrintEnvironmentJSON marshals an environment record as indented JSON through the DTO. +func PrintEnvironmentJSON(env Environment) error { + data, err := json.MarshalIndent(toEnvironmentJSON(env), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintEnvironmentHuman renders the key facts about a single environment +// record, including its full version history. +func PrintEnvironmentHuman(env Environment) { + desc := emptyValuePlaceholder + if env.Description != nil && *env.Description != "" { + desc = *env.Description + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Environment ID:\t%s\n", env.EnvironmentID) + fmt.Fprintf(w, "Name:\t%s\n", env.Name) + fmt.Fprintf(w, "Description:\t%s\n", desc) + fmt.Fprintf(w, "Latest version:\tv%s\n", strconv.Itoa(env.LatestVersion)) + fmt.Fprintf(w, "Created:\t%s\n", env.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", env.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() + + if len(env.Versions) == 0 { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Versions:")) + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"VERSION", "STATUS", "PACKAGES", "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 _, ver := range env.Versions { + t.Row( + fmt.Sprintf("v%d", ver.Version), + string(ver.Status), + joinPackages(ver.Packages), + ver.UpdatedAt.UTC().Format(timestampFormat), + ) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +// PrintEnvironmentListJSON marshals a list of environments as indented JSON through the DTO. +func PrintEnvironmentListJSON(items []EnvironmentSummary) error { + view := make([]environmentSummaryJSON, len(items)) + + for i, env := range items { + view[i] = toEnvironmentSummaryJSON(env) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintEnvironmentListHuman renders a lipgloss table summary of environments. +func PrintEnvironmentListHuman(items []EnvironmentSummary) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No environments found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"ENVIRONMENT ID", "NAME", "LATEST", "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 _, env := range items { + t.Row( + env.EnvironmentID, + env.Name, + fmt.Sprintf("v%d", env.LatestVersion), + string(env.LatestStatus), + env.UpdatedAt.UTC().Format(timestampFormat), + ) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +// joinPackages collapses a package slice into a single comma-separated +// string for tabular display, truncating at a reasonable width so the +// table stays readable in a typical terminal. +func joinPackages(packages []string) string { + const maxLen = 60 + + joined := strings.Join(packages, ",") + if len(joined) <= maxLen { + return joined + } + + return joined[:maxLen-3] + "..." +} + +// NormalizePackages takes the raw slice from a cobra StringSliceVar and +// returns a cleaned list. It returns an error when the resulting list is +// empty so callers can surface a friendly validation message. +func NormalizePackages(raw []string) ([]string, error) { + out := make([]string, 0, len(raw)) + + for _, entry := range raw { + for _, item := range strings.Split(entry, ",") { + trimmed := strings.TrimSpace(item) + if trimmed != "" { + out = append(out, trimmed) + } + } + } + + if len(out) == 0 { + return nil, errors.New("at least one package is required (use --package)") + } + + return out, nil +} diff --git a/internal/pipeline/environment_test.go b/internal/pipeline/environment_test.go new file mode 100644 index 000000000..dd9687de1 --- /dev/null +++ b/internal/pipeline/environment_test.go @@ -0,0 +1,221 @@ +// 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/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateEnvironment_PostsBody(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/environments", r.URL.Path) + + var body EnvironmentCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "ml-base", body.Name) + + if assert.NotNil(t, body.Description) { + assert.Equal(t, "for testing", *body.Description) + } + + assert.Equal(t, []string{"numpy", "pandas==2.0"}, body.Packages) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "id":"env-1", + "name":"ml-base", + "description":"for testing", + "latestVersion":1, + "versions":[{"version":1,"packages":["numpy","pandas==2.0"],"status":"CREATING","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}], + "createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z" + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateEnvironment("ml-base", "for testing", []string{"numpy", "pandas==2.0"}) + require.NoError(t, err) + assert.Equal(t, "env-1", got.EnvironmentID) + assert.Equal(t, 1, got.LatestVersion) + require.Len(t, got.Versions, 1) + assert.Equal(t, EnvironmentStatusCreating, got.Versions[0].Status) +} + +func TestCreateEnvironment_OmitsEmptyDescription(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw := map[string]any{} + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&raw)) + _, hasDesc := raw["description"] + assert.False(t, hasDesc, "description should be omitted when empty") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"env-1","name":"x","latestVersion":1,"versions":[],"createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + _, err := CreateEnvironment("x", "", []string{"numpy"}) + require.NoError(t, err) +} + +func TestListEnvironments_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/environments", r.URL.Path) + assert.Equal(t, "5", r.URL.Query().Get("offset")) + assert.Equal(t, "20", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"env-1","name":"ml-base","latestVersion":2,"latestStatus":"READY","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}],"totalCount":1,"count":1}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListEnvironments(5, 20) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "env-1", items[0].EnvironmentID) + assert.Equal(t, EnvironmentStatusReady, items[0].LatestStatus) +} + +func TestListEnvironments_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 := ListEnvironments(0, 0) + require.NoError(t, err) + assert.Empty(t, items) +} + +func TestUpdateEnvironment_PatchesBody(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/environments/env-1", r.URL.Path) + + var body EnvironmentUpdateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, []string{"scikit-learn"}, body.Packages) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"env-1","name":"ml-base","latestVersion":2, + "versions":[ + {"version":2,"packages":["scikit-learn"],"status":"CREATING","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}, + {"version":1,"packages":["numpy"],"status":"READY","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"} + ], + "createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z" + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := UpdateEnvironment("env-1", []string{"scikit-learn"}) + require.NoError(t, err) + assert.Equal(t, 2, got.LatestVersion) + require.Len(t, got.Versions, 2) + assert.Equal(t, 2, got.Versions[0].Version) +} + +func TestDeleteEnvironment_HitsCorrectURL(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/environments/env-1", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeleteEnvironment("env-1")) +} + +func TestDeleteEnvironmentVersion_HitsCorrectURL(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/environments/env-1/versions/3", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeleteEnvironmentVersion("env-1", 3)) +} + +func TestDeleteEnvironment_PropagatesNotFound(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := DeleteEnvironment("nope") + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} diff --git a/internal/pipeline/flags.go b/internal/pipeline/flags.go new file mode 100644 index 000000000..73dfb4bbd --- /dev/null +++ b/internal/pipeline/flags.go @@ -0,0 +1,64 @@ +// 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 ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// OutputFormat is the type used for the --output-format flag value. +type OutputFormat string + +const ( + OutputFormatText OutputFormat = "text" + OutputFormatJSON OutputFormat = "json" +) + +var _ pflag.Value = (*OutputFormat)(nil) + +func (f *OutputFormat) String() string { + if f == nil { + return "" + } + + return string(*f) +} + +func (f *OutputFormat) Set(s string) error { + switch s { + case string(OutputFormatText), string(OutputFormatJSON): + *f = OutputFormat(s) + + return nil + } + + return fmt.Errorf("invalid output format %q: use %s or %s", s, OutputFormatText, OutputFormatJSON) +} + +func (f *OutputFormat) Type() string { + return "format" +} + +// AddOutputFlag registers --output-format on cmd, defaulting to OutputFormatText. +// The default is written to *dest before registration so cobra renders it as +// the default value in --help. +func AddOutputFlag(cmd *cobra.Command, dest *OutputFormat) { + *dest = OutputFormatText + + cmd.Flags().Var(dest, "output-format", fmt.Sprintf("Output format (%s, %s)", OutputFormatText, OutputFormatJSON)) +} 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..aaaa5741c --- /dev/null +++ b/internal/pipeline/input_output.go @@ -0,0 +1,197 @@ +// 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"` + Version string `json:"version"` + State string `json:"state"` + Payload json.RawMessage `json:"payload"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toInputJSON(input Input) inputJSON { + scope := "draft" + version := emptyValuePlaceholder + + if input.VersionID != nil { + scope = "locked" + version = "v" + strconv.Itoa(*input.VersionID) + } + + payloadBytes, _ := json.Marshal(input.Payload) + + return inputJSON{ + InputID: input.InputID, + PipelineID: input.PipelineID, + Scope: scope, + Version: version, + State: string(input.State), + Payload: payloadBytes, + 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 = "v" + 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 = "v" + 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_payload.go b/internal/pipeline/input_payload.go new file mode 100644 index 000000000..77b242a97 --- /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 pipelines 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_test.go b/internal/pipeline/input_test.go new file mode 100644 index 000000000..8fe38900e --- /dev/null +++ b/internal/pipeline/input_test.go @@ -0,0 +1,207 @@ +// 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, "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/pipeline.go b/internal/pipeline/pipeline.go new file mode 100644 index 000000000..41e54f71f --- /dev/null +++ b/internal/pipeline/pipeline.go @@ -0,0 +1,360 @@ +// 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 ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/log" +) + +// uploadTimeout is the per-request timeout used for multipart file uploads. +const uploadTimeout = 60 * time.Second + +// Mode values accepted by the pipelines API. +const ( + ModeDraft = "draft" + ModeLocked = "locked" +) + +// PipelineVersion mirrors PipelineVersionResponse from the pipelines-api. +type PipelineVersion struct { + Version int `json:"version"` + Status string `json:"status"` + TaskNames []string `json:"taskNames,omitempty"` + PythonVersion string `json:"pythonVersion"` + ResourceBundle map[string]any `json:"resourceBundle,omitempty"` + ErrorDetail string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// Pipeline mirrors PipelineDetailResponse from the pipelines-api. +type Pipeline struct { + PipelineID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Mode string `json:"mode"` + IsActive bool `json:"isActive"` + TaskNames []string `json:"taskNames,omitempty"` + PythonVersion string `json:"pythonVersion,omitempty"` + ResourceBundle map[string]any `json:"resourceBundle,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Versions []PipelineVersion `json:"versions"` +} + +// CreateResponse mirrors PipelineCreateResponse from the pipelines-api. +// It is also returned by PATCH /pipelines/{id}. +type CreateResponse struct { + PipelineID string `json:"id"` + Name string `json:"name"` + Version int `json:"version"` + Status string `json:"status"` + Mode string `json:"mode"` + TaskNames []string `json:"taskNames,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// ListItem mirrors PipelineListItem from the pipelines-api. +type ListItem struct { + PipelineID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Mode string `json:"mode"` + IsActive bool `json:"isActive"` + LatestVersion *int `json:"latestVersion,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CreatePipeline uploads a Python file to POST /api/v2/pipeline. +func CreatePipeline(filePath, description, mode string) (*CreateResponse, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines") + if err != nil { + return nil, err + } + + fields := map[string]string{} + if description != "" { + fields["description"] = description + } + + if mode != "" { + fields["mode"] = mode + } + + var result CreateResponse + + err = doMultipart(http.MethodPost, endpoint, filePath, fields, "create pipeline", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListPipelines fetches a paginated list of pipelines from GET /api/v2/pipeline. +func ListPipelines(mode string, offset, limit int) (*DataPage[ListItem], error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines") + if err != nil { + return nil, err + } + + query := url.Values{} + if mode != "" { + query.Set("mode", mode) + } + + 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[ListItem] + + err = drapi.GetJSON(endpoint, "pipelines", &page) + if err != nil { + return nil, err + } + + return &page, nil +} + +// GetPipeline fetches a single pipeline from GET /api/v2/pipelines/{pipeline_id}. +func GetPipeline(pipelineID string) (*Pipeline, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID) + if err != nil { + return nil, err + } + + var pipeline Pipeline + + err = drapi.GetJSON(endpoint, "pipeline", &pipeline) + if err != nil { + return nil, err + } + + return &pipeline, nil +} + +// UpdatePipeline re-uploads a Python file to PATCH /api/v2/pipelines/{pipeline_id}. +func UpdatePipeline(pipelineID, filePath string) (*CreateResponse, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID) + if err != nil { + return nil, err + } + + var result CreateResponse + + err = doMultipart(http.MethodPatch, endpoint, filePath, nil, "update pipeline", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeletePipeline issues DELETE /api/v2/pipelines/{pipeline_id}. The API +// returns 204 on success. +func DeletePipeline(pipelineID string) error { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete pipeline") +} + +// LockPipeline issues PATCH /api/v2/pipelines/{pipeline_id}/mode to +// promote a draft pipeline into the locked mode. The response mirrors a +// create/update payload, with `mode` set to "locked" and `version` +// pointing at the locked version. +func LockPipeline(pipelineID string) (*CreateResponse, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID + "/mode") + if err != nil { + return nil, err + } + + var result CreateResponse + + err = doJSON(http.MethodPatch, endpoint, nil, "lock pipeline", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// doMultipart performs a multipart/form-data request with a single "file" upload +// and optional form fields, decoding the JSON response into out. +func doMultipart(method, endpoint, filePath string, fields map[string]string, info string, out any) error { + req, err := buildMultipartRequest(method, endpoint, filePath, fields) + if err != nil { + return err + } + + if info != "" { + log.Infof("%s at: %s", info, endpoint) + } + + // Only build the redacted request dump when debug logging is enabled β€” + // httputil.DumpRequestOut(req, true) drains req.Body, which silently + // breaks PATCH/POST multipart requests by leaving them with + // ContentLength=N and a 0-byte body. + if log.GetLevel() <= log.DebugLevel { + log.Debug("Request Info: \n" + config.RedactedReqInfo(req)) + } + + client := drapi.NewHTTPClient(uploadTimeout) + + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return decodeHTTPError(resp, endpoint) + } + + if out == nil { + return nil + } + + return json.NewDecoder(resp.Body).Decode(out) +} + +// buildMultipartRequest assembles the multipart body and HTTP request with +// authentication and tracing headers populated via drapi.AuthorizeRequest. +func buildMultipartRequest(method, endpoint, filePath string, fields map[string]string) (*http.Request, error) { + body, contentType, err := buildMultipartBody(filePath, fields) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, endpoint, body) + if err != nil { + return nil, err + } + + // Authorization, User-Agent, and consumer-trace are owned by drapi so + // every CLI command sends consistent headers. + err = drapi.AuthorizeRequest(req) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", contentType) + + return req, nil +} + +// decodeHTTPError reads a non-2xx response body and turns it into a meaningful error. +func decodeHTTPError(resp *http.Response, endpoint string) error { + respBody, _ := io.ReadAll(resp.Body) + + detail := extractErrorDetail(respBody) + if detail != "" { + return fmt.Errorf("HTTP %d %s: %s", resp.StatusCode, http.StatusText(resp.StatusCode), detail) + } + + return &drapi.HTTPError{StatusCode: resp.StatusCode, URL: endpoint} +} + +// buildMultipartBody constructs a multipart/form-data body containing the named +// file plus the given form fields. +func buildMultipartBody(filePath string, fields map[string]string) (*bytes.Buffer, string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, "", fmt.Errorf("open %s: %w", filePath, err) + } + + defer file.Close() + + var body bytes.Buffer + + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return nil, "", err + } + + _, err = io.Copy(part, file) + if err != nil { + return nil, "", err + } + + for key, value := range fields { + err = writer.WriteField(key, value) + if err != nil { + return nil, "", err + } + } + + err = writer.Close() + if err != nil { + return nil, "", err + } + + return &body, writer.FormDataContentType(), nil +} + +// extractErrorDetail attempts to pull a "detail" string from a JSON error body +// returned by FastAPI. Falls back to the raw body if the field is absent. +func extractErrorDetail(body []byte) string { + if len(body) == 0 { + return "" + } + + var payload struct { + Detail any `json:"detail"` + } + + err := json.Unmarshal(body, &payload) + if err == nil && payload.Detail != nil { + switch detail := payload.Detail.(type) { + case string: + return detail + default: + encoded, encErr := json.Marshal(detail) + if encErr == nil { + return string(encoded) + } + } + } + + return string(body) +} diff --git a/internal/pipeline/pipeline_lifecycle_test.go b/internal/pipeline/pipeline_lifecycle_test.go new file mode 100644 index 000000000..8ab4b24e3 --- /dev/null +++ b/internal/pipeline/pipeline_lifecycle_test.go @@ -0,0 +1,109 @@ +// 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 ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeletePipeline_Success(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", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeletePipeline("p-1")) +} + +func TestDeletePipeline_404PropagatesAsHTTPError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := DeletePipeline("p-1") + require.Error(t, err) + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} + +func TestLockPipeline_PromotesAndDecodes(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/mode", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id": "p-1", + "name": "wf", + "version": 3, + "status": "READY", + "mode": "locked", + "taskNames": ["e1"], + "createdAt": "2026-04-29T10:00:00Z" + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := LockPipeline("p-1") + require.NoError(t, err) + assert.Equal(t, "locked", got.Mode) + assert.Equal(t, 3, got.Version) + assert.Equal(t, []string{"e1"}, got.TaskNames) +} + +func TestLockPipeline_409Conflict(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"already locked"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + _, err := LockPipeline("p-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") + assert.Contains(t, err.Error(), "already locked") +} diff --git a/internal/pipeline/pipeline_output.go b/internal/pipeline/pipeline_output.go new file mode 100644 index 000000000..458228392 --- /dev/null +++ b/internal/pipeline/pipeline_output.go @@ -0,0 +1,250 @@ +// 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. + +// pipeline_output.go holds the rendering helpers shared by the top-level +// `dr pipelines` verbs (list, get, create, update, lock). +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strconv" + "strings" + "text/tabwriter" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +const ( + timestampFormat = "2006-01-02 15:04 UTC" + emptyValuePlaceholder = "β€”" +) + +// RenderPipeline routes a single pipeline to JSON or human output. +func RenderPipeline(format OutputFormat, p Pipeline) error { + if format == OutputFormatJSON { + return printPipelineJSON(p) + } + + printPipelineHuman(p) + + return nil +} + +// RenderPipelines routes a pipeline list to JSON or human output. +func RenderPipelines(format OutputFormat, page DataPage[ListItem]) error { + if format == OutputFormatJSON { + return printPipelinesJSON(page) + } + + printPipelinesHuman(page) + + return nil +} + +// RenderCreateResponse routes a CreateResponse to JSON or human output. +func RenderCreateResponse(format OutputFormat, result CreateResponse) error { + if format == OutputFormatJSON { + return printCreateResponseJSON(result) + } + + printCreateResponseHuman(result) + + return nil +} + +func printPipelineJSON(p Pipeline) error { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printPipelinesJSON(page DataPage[ListItem]) error { + data, err := json.MarshalIndent(page.Data, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printCreateResponseJSON(result CreateResponse) error { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printPipelinesHuman(page DataPage[ListItem]) { + if len(page.Data) == 0 { + fmt.Println(tui.DimStyle.Render("No pipelines found.")) + + return + } + + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Showing %d of %d", len(page.Data), page.TotalCount))) + fmt.Println() + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"ID", "NAME", "MODE", "ACTIVE", "VERSION", "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 _, item := range page.Data { + latest := emptyValuePlaceholder + if item.LatestVersion != nil { + latest = "v" + strconv.Itoa(*item.LatestVersion) + } + + updated := item.UpdatedAt.UTC().Format(timestampFormat) + active := strconv.FormatBool(item.IsActive) + + t.Row(item.PipelineID, item.Name, item.Mode, active, latest, updated) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +func printPipelineHuman(p Pipeline) { + description := emptyValuePlaceholder + if p.Description != "" { + description = p.Description + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "ID:\t%s\n", p.PipelineID) + fmt.Fprintf(w, "Name:\t%s\n", p.Name) + fmt.Fprintf(w, "Description:\t%s\n", description) + fmt.Fprintf(w, "Mode:\t%s\n", p.Mode) + fmt.Fprintf(w, "Active:\t%t\n", p.IsActive) + fmt.Fprintf(w, "Created:\t%s\n", p.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", p.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() + + if len(p.Versions) == 0 { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Versions (%d):", len(p.Versions)))) + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"VERSION", "STATUS", "PYTHON", "CREATED", "TASKS"} + + createdCol := slices.Index(headers, "CREATED") + + 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 == createdCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, ver := range p.Versions { + tasks := emptyValuePlaceholder + if len(ver.TaskNames) > 0 { + tasks = strings.Join(ver.TaskNames, ", ") + } + + python := ver.PythonVersion + if python == "" { + python = emptyValuePlaceholder + } + + t.Row( + "v"+strconv.Itoa(ver.Version), + ver.Status, + python, + ver.CreatedAt.UTC().Format(timestampFormat), + tasks, + ) + } + + fmt.Fprintln(os.Stdout, t.Render()) + + for _, ver := range p.Versions { + if ver.ErrorDetail == "" { + continue + } + + fmt.Println(tui.DimStyle.Render(fmt.Sprintf(" v%d error: %s", ver.Version, ver.ErrorDetail))) + } +} + +func printCreateResponseHuman(result CreateResponse) { + tasks := emptyValuePlaceholder + if len(result.TaskNames) > 0 { + tasks = strings.Join(result.TaskNames, ", ") + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Pipeline ID:\t%s\n", result.PipelineID) + fmt.Fprintf(w, "Name:\t%s\n", result.Name) + fmt.Fprintf(w, "Version:\t%d\n", result.Version) + fmt.Fprintf(w, "Status:\t%s\n", result.Status) + fmt.Fprintf(w, "Mode:\t%s\n", result.Mode) + fmt.Fprintf(w, "Tasks:\t%s\n", tasks) + fmt.Fprintf(w, "Created:\t%s\n", result.CreatedAt.UTC().Format(timestampFormat)) + + w.Flush() +} diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go new file mode 100644 index 000000000..e3c76b05b --- /dev/null +++ b/internal/pipeline/pipeline_test.go @@ -0,0 +1,170 @@ +// 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 ( + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stringReadCloser wraps a string reader to satisfy http.Response.Body. +type stringReadCloser struct{ *strings.Reader } + +func (s *stringReadCloser) Close() error { return nil } + +func bodyOf(s string) io.ReadCloser { + return &stringReadCloser{strings.NewReader(s)} +} + +func TestExtractErrorDetail(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + name: "empty body", + body: "", + want: "", + }, + { + name: "string detail", + body: `{"detail": "lattice missing"}`, + want: "lattice missing", + }, + { + name: "object detail", + body: `{"detail": {"field": "name", "msg": "required"}}`, + want: `{"field":"name","msg":"required"}`, + }, + { + name: "no detail field falls back to raw body", + body: `{"unrelated": "value"}`, + want: `{"unrelated": "value"}`, + }, + { + name: "malformed JSON falls back to raw body", + body: "not json", + want: "not json", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractErrorDetail([]byte(tc.body)) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestDecodeHTTPError_WithDetail(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Body: bodyOf(`{"detail": "lattice missing"}`), + } + + err := decodeHTTPError(resp, "http://example/api/v2/pipelines") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 400") + assert.Contains(t, err.Error(), "lattice missing") +} + +func TestDecodeHTTPError_WithoutDetail_ReturnsHTTPError(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusNotFound, + Body: bodyOf(""), + } + + err := decodeHTTPError(resp, "http://example/api/v2/pipelines/abc") + require.Error(t, err) + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) + assert.Equal(t, "http://example/api/v2/pipelines/abc", httpErr.URL) +} + +func TestBuildMultipartBody_IncludesFileAndFields(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "pipeline.py") + + const content = "from covalent import lattice\n" + + require.NoError(t, writeFile(filePath, content)) + + fields := map[string]string{ + "description": "draft 1", + "mode": "draft", + } + + body, contentType, err := buildMultipartBody(filePath, fields) + require.NoError(t, err) + assert.NotEmpty(t, body.Bytes()) + + mediaType, params, err := mime.ParseMediaType(contentType) + require.NoError(t, err) + assert.Equal(t, "multipart/form-data", mediaType) + require.NotEmpty(t, params["boundary"]) + + reader := multipart.NewReader(body, params["boundary"]) + + seen := map[string]string{} + + for { + part, partErr := reader.NextPart() + if errors.Is(partErr, io.EOF) { + break + } + + require.NoError(t, partErr) + + buf, readErr := io.ReadAll(part) + require.NoError(t, readErr) + + name := part.FormName() + if name == "file" { + assert.Equal(t, "pipeline.py", part.FileName()) + } + + seen[name] = string(buf) + } + + assert.Equal(t, content, seen["file"]) + assert.Equal(t, "draft 1", seen["description"]) + assert.Equal(t, "draft", seen["mode"]) +} + +func TestBuildMultipartBody_MissingFile(t *testing.T) { + _, _, err := buildMultipartBody("/no/such/file.py", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "open /no/such/file.py") +} + +// writeFile is a tiny helper that avoids dragging os into every test. +func writeFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o600) +} diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go new file mode 100644 index 000000000..dbc7d0a35 --- /dev/null +++ b/internal/pipeline/run.go @@ -0,0 +1,164 @@ +// 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. + +// run.go wraps the pipeline run endpoints described in +// pipelines-api/.../controllers/pipeline_dispatch.py. The CLI exposes the +// same draft/locked URL split as inputs via the shared Scope helpers. +// +// The wire format and server URL paths still use the legacy term +// "dispatch" (e.g. /dispatches, dispatch_id). JSON tags and endpoint +// segments are preserved to keep the API contract intact while the Go +// surface is renamed to "run" to match the new product vocabulary. + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" + "time" +) + +// Run lifecycle states (mirrors PipelineDispatchStatus on the wire). +const ( + RunStatusPending = "PENDING" + RunStatusRunning = "RUNNING" + RunStatusCompleted = "COMPLETED" + RunStatusFailed = "FAILED" + RunStatusCancelled = "CANCELLED" + RunStatusErrored = "ERRORED" +) + +// Run mirrors PipelineDispatchResponse from the pipelines-api. +type Run struct { + RunID string `json:"id"` + PipelineID string `json:"pipelineId"` + VersionID *int `json:"versionId,omitempty"` + InputID string `json:"inputId"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` + TriggeredBy string `json:"triggeredBy"` + Status string `json:"status"` + ErrorDetail string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// RunStatus mirrors PipelineDispatchStatusResponse β€” the lightweight +// polling-friendly shape returned by GET .../status. +type RunStatus struct { + RunID string `json:"id"` + Status string `json:"status"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` +} + +// RunCreateRequest mirrors PipelineDispatchCreateRequest. +type RunCreateRequest struct { + InputID string `json:"input_id"` +} + +// CreateRun starts a new run for the given input. Returns the +// freshly-created Run (status PENDING). +func CreateRun(pipelineID string, scope Scope, version *int, inputID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + body := RunCreateRequest{InputID: inputID} + + var result Run + + err = doJSON(http.MethodPost, endpoint, body, "create run", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListRuns returns a paginated slice of runs for the given scope. +func ListRuns(pipelineID string, scope Scope, version *int, offset, limit int) ([]Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + 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[Run] + + err = doJSON(http.MethodGet, endpoint, nil, "runs", &page) + if err != nil { + return nil, err + } + + return page.Data, nil +} + +// GetRun fetches a single run by id within the given scope. +func GetRun(pipelineID string, scope Scope, version *int, runID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return nil, err + } + + var run Run + + err = doJSON(http.MethodGet, endpoint, nil, "run", &run) + if err != nil { + return nil, err + } + + return &run, nil +} + +// GetRunStatus calls the lightweight GET .../status endpoint useful for +// polling without re-downloading the full run record. +func GetRunStatus(pipelineID string, scope Scope, version *int, runID string) (*RunStatus, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID+"/status") + if err != nil { + return nil, err + } + + var status RunStatus + + err = doJSON(http.MethodGet, endpoint, nil, "run status", &status) + if err != nil { + return nil, err + } + + return &status, nil +} + +// CancelRun issues a DELETE on a run, transitioning it to CANCELLED if +// it is still in a non-terminal state. +func CancelRun(pipelineID string, scope Scope, version *int, runID string) error { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return err + } + + return doDelete(endpoint, "cancel run") +} diff --git a/internal/pipeline/run_output.go b/internal/pipeline/run_output.go new file mode 100644 index 000000000..562f2e42c --- /dev/null +++ b/internal/pipeline/run_output.go @@ -0,0 +1,254 @@ +// 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. + +// run_output.go holds the rendering helpers shared by the +// `dr pipelines run` verbs. +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" +) + +// runJSON is the CLI-facing shape used for `--output-format json`. It mirrors +// Run but renames the wire-level fields to the CLI's `run` +// vocabulary (`run_id`, `covalent_run_id`). Decoding still happens +// through Run, which keeps the API wire tags intact. +type runJSON struct { + RunID string `json:"run_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentRunID string `json:"covalent_run_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toRunJSON(r Run) runJSON { + return runJSON{ + RunID: r.RunID, + PipelineID: r.PipelineID, + VersionID: r.VersionID, + InputID: r.InputID, + CovalentRunID: r.CovalentDispatchID, + TriggeredBy: r.TriggeredBy, + Status: r.Status, + ErrorDetail: r.ErrorDetail, + CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: r.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// runStatusJSON mirrors RunStatus with CLI-vocabulary keys. +type runStatusJSON struct { + RunID string `json:"run_id"` + Status string `json:"status"` + CovalentRunID string `json:"covalent_run_id,omitempty"` +} + +func toRunStatusJSON(s RunStatus) runStatusJSON { + return runStatusJSON{ + RunID: s.RunID, + Status: s.Status, + CovalentRunID: s.CovalentDispatchID, + } +} + +// RenderRun routes a single run to JSON or human output. +func RenderRun(format OutputFormat, r Run) error { + if format == OutputFormatJSON { + return PrintRunJSON(r) + } + + PrintRunHuman(r) + + return nil +} + +// RenderRuns routes a list of runs to JSON or human output. +func RenderRuns(format OutputFormat, items []Run) error { + if format == OutputFormatJSON { + return PrintRunListJSON(items) + } + + PrintRunListHuman(items) + + return nil +} + +// RenderRunStatus routes a run status to JSON or human output. +func RenderRunStatus(format OutputFormat, s RunStatus) error { + if format == OutputFormatJSON { + return PrintStatusJSON(s) + } + + PrintStatusHuman(s) + + return nil +} + +// PrintRunJSON marshals a run as indented JSON using CLI-vocabulary keys. +func PrintRunJSON(r Run) error { + data, err := json.MarshalIndent(toRunJSON(r), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunHuman renders a single run in a human-friendly form. +func PrintRunHuman(r Run) { + scope := "draft" + versionDisplay := emptyValuePlaceholder + + if r.VersionID != nil { + scope = "locked" + versionDisplay = "v" + strconv.Itoa(*r.VersionID) + } + + covalent := r.CovalentDispatchID + if covalent == "" { + covalent = emptyValuePlaceholder + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", r.RunID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", r.PipelineID) + fmt.Fprintf(w, "Scope:\t%s\n", scope) + fmt.Fprintf(w, "Version:\t%s\n", versionDisplay) + fmt.Fprintf(w, "Input ID:\t%s\n", r.InputID) + fmt.Fprintf(w, "Status:\t%s\n", r.Status) + fmt.Fprintf(w, "Triggered By:\t%s\n", r.TriggeredBy) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) + + if r.ErrorDetail != "" { + fmt.Fprintf(w, "Error:\t%s\n", r.ErrorDetail) + } + + fmt.Fprintf(w, "Created:\t%s\n", r.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", r.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() +} + +// PrintRunListJSON marshals a list of runs as indented JSON using +// CLI-vocabulary keys. +func PrintRunListJSON(items []Run) error { + view := make([]runJSON, len(items)) + + for i, r := range items { + view[i] = toRunJSON(r) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunListHuman renders a lipgloss table summary of runs. +func PrintRunListHuman(items []Run) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No runs found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"RUN ID", "SCOPE", "VERSION", "STATUS", "TRIGGER", "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 _, r := range items { + scope := "draft" + ver := emptyValuePlaceholder + + if r.VersionID != nil { + scope = "locked" + ver = "v" + strconv.Itoa(*r.VersionID) + } + + t.Row(r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(timestampFormat)) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +// PrintStatusJSON marshals a lightweight status response as indented JSON +// using CLI-vocabulary keys. +func PrintStatusJSON(s RunStatus) error { + data, err := json.MarshalIndent(toRunStatusJSON(s), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintStatusHuman renders a lightweight status response. +func PrintStatusHuman(s RunStatus) { + covalent := s.CovalentDispatchID + if covalent == "" { + covalent = emptyValuePlaceholder + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", s.RunID) + fmt.Fprintf(w, "Status:\t%s\n", s.Status) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) + + w.Flush() +} diff --git a/internal/pipeline/run_test.go b/internal/pipeline/run_test.go new file mode 100644 index 000000000..3fd14228d --- /dev/null +++ b/internal/pipeline/run_test.go @@ -0,0 +1,168 @@ +// 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 TestCreateRun_DraftURLAndBody(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/dispatches", r.URL.Path) + + var body RunCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "in-1", body.InputID) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateRun("p-1", ScopeDraft, nil, "in-1") + require.NoError(t, err) + assert.Equal(t, "d-1", got.RunID) + assert.Equal(t, RunStatusPending, got.Status) +} + +func TestCreateRun_LockedURL(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/dispatches", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","versionId":2,"inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := CreateRun("p-1", ScopeLocked, &v, "in-1") + require.NoError(t, err) + require.NotNil(t, got.VersionID) + assert.Equal(t, 2, *got.VersionID) +} + +func TestListRuns_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/dispatches", 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":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"RUNNING"}],"totalCount":1,"count":1}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListRuns("p-1", ScopeDraft, nil, 10, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, RunStatusRunning, items[0].Status) +} + +func TestGetRun_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/dispatches/d-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"COMPLETED"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetRun("p-1", ScopeDraft, nil, "d-1") + require.NoError(t, err) + assert.Equal(t, RunStatusCompleted, got.Status) +} + +func TestGetRunStatus_StatusEndpointURL(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/dispatches/d-1/status", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","status":"RUNNING","covalentDispatchId":"cov-x"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := GetRunStatus("p-1", ScopeLocked, &v, "d-1") + require.NoError(t, err) + assert.Equal(t, RunStatusRunning, got.Status) + assert.Equal(t, "cov-x", got.CovalentDispatchID) +} + +func TestCancelRun_DeletesDraftURL(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/dispatches/d-1", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, CancelRun("p-1", ScopeDraft, nil, "d-1")) +} + +func TestCancelRun_PropagatesConflict(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"already terminal"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := CancelRun("p-1", ScopeDraft, nil, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") + assert.Contains(t, err.Error(), "already terminal") +} 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/scope.go b/internal/pipeline/scope.go new file mode 100644 index 000000000..bdae7f1b5 --- /dev/null +++ b/internal/pipeline/scope.go @@ -0,0 +1,124 @@ +// 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. + +// scope.go contains the shared draft/locked scope resolution used by the +// input and run CLI commands. The pipelines API exposes two URL shapes +// for these resources: +// +// draft -> /pipelines/{id}/ +// locked -> /pipelines/{id}/versions/{ver}/ +// +// The CLI surfaces the choice with optional --scope and --version flags; +// ResolveScope encapsulates the precedence rules so every verb behaves +// consistently. PipelinePath then turns the resolved scope into the right +// URL fragment. + +package pipeline + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/datarobot/cli/internal/config" +) + +// Scope identifies whether a request targets the mutable draft of a pipeline +// or a locked, frozen version. +type Scope string + +const ( + ScopeDraft Scope = "draft" + ScopeLocked Scope = "locked" +) + +// ResolveScope applies the CLI precedence rules: +// +// - empty scope, no version -> draft +// - empty scope, version=N -> locked (auto-promoted from --version) +// - scope=draft + version -> error (draft has no versions) +// - scope=locked + no version -> error (locked requires --version) +// - scope=draft + no version -> draft +// - scope=locked + version=N -> locked +// +// `version` is a pointer because 0 is a real value sent by the user; nil +// means the flag was not provided. +func ResolveScope(scope string, version *int) (Scope, *int, error) { + normalized := strings.ToLower(strings.TrimSpace(scope)) + + switch normalized { + case "": + if version == nil { + return ScopeDraft, nil, nil + } + + return ScopeLocked, version, nil + case string(ScopeDraft): + if version != nil { + return "", nil, errors.New("--scope=draft cannot be combined with --version") + } + + return ScopeDraft, nil, nil + case string(ScopeLocked): + if version == nil { + return "", nil, errors.New("--scope=locked requires --version=") + } + + return ScopeLocked, version, nil + default: + return "", nil, fmt.Errorf("invalid --scope: %q (supported: draft, locked)", scope) + } +} + +// PipelinePath builds an API path for a sub-resource hanging off a single +// pipeline. The leading "/api/v2" prefix is added by config.GetEndpointURL, +// so this returns just the segment beginning at "/pipelines". +// +// suffix should NOT start with a slash (e.g. "inputs", "inputs/abc"). +func PipelinePath(pipelineID string, scope Scope, version *int, suffix string) (string, error) { + if pipelineID == "" { + return "", errors.New("pipeline id is required") + } + + suffix = strings.TrimPrefix(suffix, "/") + + base := "/api/v2/pipelines/" + pipelineID + + if scope == ScopeLocked { + if version == nil { + return "", errors.New("locked scope requires a version") + } + + base += "/versions/" + strconv.Itoa(*version) + } + + if suffix == "" { + return base, nil + } + + return base + "/" + suffix, nil +} + +// EndpointFor combines PipelinePath with config.GetEndpointURL so callers +// can write a single line. It returns a fully-qualified URL ready to be +// passed to drapi.GetJSON / doJSON / doDelete. +func EndpointFor(pipelineID string, scope Scope, version *int, suffix string) (string, error) { + path, err := PipelinePath(pipelineID, scope, version, suffix) + if err != nil { + return "", err + } + + return config.GetEndpointURL(path) +} diff --git a/internal/pipeline/scope_test.go b/internal/pipeline/scope_test.go new file mode 100644 index 000000000..510caf6b9 --- /dev/null +++ b/internal/pipeline/scope_test.go @@ -0,0 +1,146 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func intPtr(v int) *int { return &v } + +func TestResolveScope(t *testing.T) { + tests := []struct { + name string + scope string + version *int + wantScope Scope + wantVersion *int + wantErr string + }{ + { + name: "no scope, no version -> draft", + wantScope: ScopeDraft, + }, + { + name: "no scope, version=2 -> locked v2 (auto)", + version: intPtr(2), + wantScope: ScopeLocked, + wantVersion: intPtr(2), + }, + { + name: "scope=draft, no version -> draft", + scope: "draft", + wantScope: ScopeDraft, + }, + { + name: "scope=draft + version -> error", + scope: "draft", + version: intPtr(1), + wantErr: "draft cannot be combined", + }, + { + name: "scope=locked, no version -> error", + scope: "locked", + wantErr: "requires --version", + }, + { + name: "scope=locked + version=3", + scope: "locked", + version: intPtr(3), + wantScope: ScopeLocked, + wantVersion: intPtr(3), + }, + { + name: "invalid scope", + scope: "weird", + wantErr: "invalid --scope", + }, + { + name: "case-insensitive scope", + scope: "DRAFT", + wantScope: ScopeDraft, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + scope, version, err := ResolveScope(tc.scope, tc.version) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantScope, scope) + + if tc.wantVersion == nil { + assert.Nil(t, version) + } else { + require.NotNil(t, version) + assert.Equal(t, *tc.wantVersion, *version) + } + }) + } +} + +func TestPipelinePath(t *testing.T) { + t.Run("draft scope", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeDraft, nil, "inputs") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc/inputs", got) + }) + + t.Run("locked scope with version", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeLocked, intPtr(2), "schedules") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc/versions/2/schedules", got) + }) + + t.Run("nested suffix", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeLocked, intPtr(2), "dispatches/xyz/status") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc/versions/2/dispatches/xyz/status", got) + }) + + t.Run("empty suffix returns base", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeDraft, nil, "") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc", got) + }) + + t.Run("leading slash on suffix is tolerated", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeDraft, nil, "/inputs") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc/inputs", got) + }) + + t.Run("locked without version is an error", func(t *testing.T) { + _, err := PipelinePath("abc", ScopeLocked, nil, "inputs") + require.Error(t, err) + assert.Contains(t, err.Error(), "version") + }) + + t.Run("missing pipeline id is an error", func(t *testing.T) { + _, err := PipelinePath("", ScopeDraft, nil, "inputs") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline id") + }) +} diff --git a/internal/pipeline/transport.go b/internal/pipeline/transport.go new file mode 100644 index 000000000..4fdcb29ce --- /dev/null +++ b/internal/pipeline/transport.go @@ -0,0 +1,156 @@ +// 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. + +// transport.go centralizes the request execution paths used by the +// non-multipart pipelines endpoints (inputs, runs, schedules): +// +// - doJSON - POST/PATCH a JSON body, optionally decode response. +// - doDelete - DELETE returning 204 (or any 2xx with empty body). +// +// Authorization, User-Agent, and consumer-trace headers are owned by the +// shared drapi.AuthorizeRequest helper so the headers stay consistent with +// every other CLI command. + +package pipeline + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/log" +) + +// jsonTimeout is used for JSON request/response endpoints. These should +// finish quickly compared to multipart uploads, so we keep the default +// drapi timeout. +const jsonTimeout = drapi.DefaultClientTimeout + +// doJSON performs a request with a JSON-encoded body. If body is nil the +// request is sent with no body (useful for status-only POSTs). If out is +// nil the response body is discarded. +func doJSON(method, endpoint string, body any, info string, out any) error { + req, err := buildJSONRequest(method, endpoint, body) + if err != nil { + return err + } + + if info != "" { + log.Infof("%s at: %s", info, endpoint) + } + + if log.GetLevel() <= log.DebugLevel { + log.Debug("Request Info: \n" + config.RedactedReqInfo(req)) + } + + client := drapi.NewHTTPClient(jsonTimeout) + + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return decodeHTTPError(resp, endpoint) + } + + if out == nil { + return nil + } + + return json.NewDecoder(resp.Body).Decode(out) +} + +// buildJSONRequest assembles an authenticated *http.Request with a +// JSON-encoded body. Extracted from doJSON to keep doJSON's cyclomatic +// complexity within lint limits. +func buildJSONRequest(method, endpoint string, body any) (*http.Request, error) { + reqBody := &bytes.Buffer{} + + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + + reqBody = bytes.NewBuffer(buf) + } + + req, err := http.NewRequest(method, endpoint, reqBody) + if err != nil { + return nil, err + } + + err = drapi.AuthorizeRequest(req) + if err != nil { + return nil, err + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +// DataPage is the pagination envelope returned by all pipelines-api list endpoints +// (action 056 β€” DataPage[T] convention). +type DataPage[T any] struct { + Data []T `json:"data"` + TotalCount int `json:"totalCount"` + Count int `json:"count"` + Next *string `json:"next"` + Previous *string `json:"previous"` +} + +// doDelete sends a DELETE and treats any 2xx response as success. The +// response body is drained but ignored. +func doDelete(endpoint, info string) error { + req, err := http.NewRequest(http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + err = drapi.AuthorizeRequest(req) + if err != nil { + return err + } + + if info != "" { + log.Infof("%s at: %s", info, endpoint) + } + + if log.GetLevel() <= log.DebugLevel { + log.Debug("Request Info: \n" + config.RedactedReqInfo(req)) + } + + client := drapi.NewHTTPClient(jsonTimeout) + + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return decodeHTTPError(resp, endpoint) + } + + return nil +} diff --git a/internal/pipeline/transport_test.go b/internal/pipeline/transport_test.go new file mode 100644 index 000000000..fb0b578b5 --- /dev/null +++ b/internal/pipeline/transport_test.go @@ -0,0 +1,197 @@ +// 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" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// installSkipAuth configures viper so drapi.AuthorizeRequest does not +// attempt to verify a token over the network. It is safe to call from +// every test; previous values are restored at cleanup. +func installSkipAuth(t *testing.T) { + t.Helper() + + prevSkip := viperx.GetBool("skip_auth") + prevTok := viperx.GetString(config.DataRobotAPIKey) + + viperx.Set("skip_auth", true) + viperx.Set(config.DataRobotAPIKey, "test-token") + + t.Cleanup(func() { + viperx.Set("skip_auth", prevSkip) + viperx.Set(config.DataRobotAPIKey, prevTok) + }) +} + +func TestBuildJSONRequest_BodyAndHeaders(t *testing.T) { + installSkipAuth(t) + + req, err := buildJSONRequest(http.MethodPost, "http://example/x", map[string]string{"a": "b"}) + require.NoError(t, err) + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + assert.NotEmpty(t, req.Header.Get("Authorization")) + + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + var parsed map[string]string + + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "b", parsed["a"]) +} + +func TestBuildJSONRequest_NilBodyOmitsContentType(t *testing.T) { + installSkipAuth(t) + + req, err := buildJSONRequest(http.MethodPatch, "http://example/x", nil) + require.NoError(t, err) + assert.Empty(t, req.Header.Get("Content-Type")) +} + +func TestDoJSON_DecodesSuccess(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, "application/json", r.Header.Get("Content-Type")) + + var body map[string]string + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "in-1", body["input_id"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok": true}`)) + })) + + defer srv.Close() + + var out map[string]bool + + err := doJSON(http.MethodPost, srv.URL, map[string]string{"input_id": "in-1"}, "test", &out) + require.NoError(t, err) + assert.True(t, out["ok"]) +} + +func TestDoJSON_NilOutDiscardsResponse(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ignored": "value"}`)) + })) + + defer srv.Close() + + require.NoError(t, doJSON(http.MethodGet, srv.URL, nil, "", nil)) +} + +func TestDoJSON_404ReturnsHTTPError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + defer srv.Close() + + var out map[string]any + + err := doJSON(http.MethodGet, srv.URL, nil, "", &out) + require.Error(t, err) + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} + +func TestDoJSON_400WithDetailReturnsFormattedError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"detail": "lattice missing"}`)) + })) + + defer srv.Close() + + var out map[string]any + + err := doJSON(http.MethodPost, srv.URL, map[string]string{}, "", &out) + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 400") + assert.Contains(t, err.Error(), "lattice missing") +} + +func TestDoDelete_SuccessOn2xx(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.NotEmpty(t, r.Header.Get("Authorization")) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + require.NoError(t, doDelete(srv.URL, "test")) +} + +func TestDoDelete_404ReturnsHTTPError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + defer srv.Close() + + err := doDelete(srv.URL, "test") + require.Error(t, err) + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} + +func TestDoDelete_409WithDetailReturnsFormattedError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail": "already terminal"}`)) + })) + + defer srv.Close() + + err := doDelete(srv.URL, "test") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") + assert.Contains(t, err.Error(), "already terminal") +} diff --git a/internal/pipeline/version.go b/internal/pipeline/version.go new file mode 100644 index 000000000..76887713f --- /dev/null +++ b/internal/pipeline/version.go @@ -0,0 +1,137 @@ +// 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. + +// version.go wraps the version-scoped read endpoints exposed by +// pipelines-api/.../controllers/pipeline.py: +// +// GET /pipelines/{id}/versions +// GET /pipelines/{id}/versions/{ver} +// GET /pipelines/{id}/graph (draft DAG) +// GET /pipelines/{id}/versions/{ver}/graph (locked DAG) +// +// The list/detail endpoints return PipelineVersion records (already +// defined in pipeline.go). The two graph endpoints return a free-form +// dict; we surface its known shape via the Graph struct below. + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" + + "github.com/datarobot/cli/internal/config" +) + +// GraphNode mirrors PipelineGraphNode from the graph endpoint. IDs are +// integer indices assigned by Covalent's TransportGraph. +type GraphNode struct { + ID int `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Source *string `json:"source,omitempty"` + ResourceBundle any `json:"resourceBundle,omitempty"` + TaskGroupID any `json:"taskGroupId,omitempty"` +} + +// GraphEdge mirrors PipelineGraphEdge. Source and Target are integer node IDs. +// EdgeName, ParamType, and ArgIndex carry Covalent transport metadata. +type GraphEdge struct { + Source int `json:"source"` + Target int `json:"target"` + EdgeName string `json:"edgeName,omitempty"` + ParamType string `json:"paramType,omitempty"` + ArgIndex int `json:"argIndex,omitempty"` +} + +// GraphPipeline mirrors PipelineGraphMeta β€” the pipeline header in the +// graph payload (action 059: wire key renamed from "lattice" to "pipeline"). +type GraphPipeline struct { + Name string `json:"name"` + PythonVersion string `json:"pythonVersion"` +} + +// Graph mirrors the JSON returned by GET /pipelines/{id}/graph and +// GET /pipelines/{id}/versions/{ver}/graph. +type Graph struct { + Pipeline GraphPipeline `json:"pipeline"` + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` +} + +// ListVersions fetches a paginated list of versions for a pipeline. +func ListVersions(pipelineID string, offset, limit int) ([]PipelineVersion, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID + "/versions") + 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[PipelineVersion] + + err = doJSON(http.MethodGet, endpoint, nil, "pipeline versions", &page) + if err != nil { + return nil, err + } + + return page.Data, nil +} + +// GetVersion fetches a single version of a pipeline. +func GetVersion(pipelineID string, versionID int) (*PipelineVersion, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID + "/versions/" + strconv.Itoa(versionID)) + if err != nil { + return nil, err + } + + var version PipelineVersion + + err = doJSON(http.MethodGet, endpoint, nil, "pipeline version", &version) + if err != nil { + return nil, err + } + + return &version, nil +} + +// GetGraph fetches the DAG visualization payload for a pipeline. When +// scope is ScopeDraft the latest draft graph is returned; with +// ScopeLocked + version, the graph for that locked version is returned. +func GetGraph(pipelineID string, scope Scope, version *int) (*Graph, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "graph") + if err != nil { + return nil, err + } + + var graph Graph + + err = doJSON(http.MethodGet, endpoint, nil, "graph", &graph) + if err != nil { + return nil, err + } + + return &graph, nil +} diff --git a/internal/pipeline/version_output.go b/internal/pipeline/version_output.go new file mode 100644 index 000000000..0a8ebc16e --- /dev/null +++ b/internal/pipeline/version_output.go @@ -0,0 +1,190 @@ +// 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. + +// version_output.go contains rendering helpers shared by the +// `dr pipelines version` verbs. +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// versionJSON is the CLI-facing DTO used for `--output-format json`. +type versionJSON struct { + Version int `json:"version"` + Status string `json:"status"` + TaskNames []string `json:"task_names,omitempty"` + PythonVersion string `json:"python_version,omitempty"` + ResourceBundle map[string]any `json:"resource_bundle,omitempty"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` +} + +func toVersionJSON(v PipelineVersion) versionJSON { + return versionJSON{ + Version: v.Version, + Status: v.Status, + TaskNames: v.TaskNames, + PythonVersion: v.PythonVersion, + ResourceBundle: v.ResourceBundle, + ErrorDetail: v.ErrorDetail, + CreatedAt: v.CreatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderVersion routes a single version to JSON or human output. +func RenderVersion(format OutputFormat, v PipelineVersion) error { + if format == OutputFormatJSON { + return PrintVersionJSON(v) + } + + PrintVersionHuman(v) + + return nil +} + +// RenderVersions routes a list of versions to JSON or human output. +func RenderVersions(format OutputFormat, items []PipelineVersion) error { + if format == OutputFormatJSON { + return PrintVersionListJSON(items) + } + + PrintVersionListHuman(items) + + return nil +} + +// PrintVersionJSON marshals a single version as indented JSON through the DTO. +func PrintVersionJSON(v PipelineVersion) error { + data, err := json.MarshalIndent(toVersionJSON(v), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintVersionHuman renders the key facts about a single version. +func PrintVersionHuman(v PipelineVersion) { + tasks := emptyValuePlaceholder + if len(v.TaskNames) > 0 { + tasks = strings.Join(v.TaskNames, ", ") + } + + python := v.PythonVersion + if python == "" { + python = emptyValuePlaceholder + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Version:\tv%s\n", strconv.Itoa(v.Version)) + fmt.Fprintf(w, "Status:\t%s\n", v.Status) + fmt.Fprintf(w, "Python Version:\t%s\n", python) + fmt.Fprintf(w, "Tasks:\t%s\n", tasks) + + if v.ErrorDetail != "" { + fmt.Fprintf(w, "Error:\t%s\n", v.ErrorDetail) + } + + fmt.Fprintf(w, "Created:\t%s\n", v.CreatedAt.UTC().Format(timestampFormat)) + + w.Flush() +} + +// PrintVersionListJSON marshals a list of versions as indented JSON through the DTO. +func PrintVersionListJSON(items []PipelineVersion) error { + view := make([]versionJSON, len(items)) + + for i, v := range items { + view[i] = toVersionJSON(v) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintVersionListHuman renders a lipgloss table summary of versions. +func PrintVersionListHuman(items []PipelineVersion) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No versions found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"VERSION", "STATUS", "PYTHON", "CREATED", "TASKS"} + + createdCol := slices.Index(headers, "CREATED") + + 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 == createdCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, v := range items { + tasks := emptyValuePlaceholder + if len(v.TaskNames) > 0 { + tasks = strings.Join(v.TaskNames, ", ") + } + + python := v.PythonVersion + if python == "" { + python = emptyValuePlaceholder + } + + t.Row( + "v"+strconv.Itoa(v.Version), + v.Status, + python, + v.CreatedAt.UTC().Format(timestampFormat), + tasks, + ) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} diff --git a/internal/pipeline/version_test.go b/internal/pipeline/version_test.go new file mode 100644 index 000000000..21d492eb4 --- /dev/null +++ b/internal/pipeline/version_test.go @@ -0,0 +1,110 @@ +// 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 ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListVersions_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", r.URL.Path) + assert.Equal(t, "10", r.URL.Query().Get("offset")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"version":1,"status":"READY","pythonVersion":"3.12","createdAt":"2026-04-29T10:00:00Z"}],"totalCount":1,"count":1}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListVersions("p-1", 10, 0) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, 1, items[0].Version) + assert.Equal(t, "READY", items[0].Status) +} + +func TestGetVersion_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", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":2,"status":"READY","pythonVersion":"3.12","createdAt":"2026-04-29T10:00:00Z"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetVersion("p-1", 2) + require.NoError(t, err) + assert.Equal(t, 2, got.Version) +} + +func TestGetGraph_DraftURL(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/graph", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "pipeline": {"name":"wf","pythonVersion":"3.12"}, + "nodes": [{"id":0,"type":"function","name":"wf"}], + "edges": [] + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetGraph("p-1", ScopeDraft, nil) + require.NoError(t, err) + assert.Equal(t, "wf", got.Pipeline.Name) + require.Len(t, got.Nodes, 1) + assert.Equal(t, "function", got.Nodes[0].Type) +} + +func TestGetGraph_LockedURL(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/3/graph", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"pipeline":{"name":"wf","pythonVersion":"3.12"},"nodes":[],"edges":[]}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 3 + got, err := GetGraph("p-1", ScopeLocked, &v) + require.NoError(t, err) + assert.Empty(t, got.Nodes) +}