diff --git a/cmd/dotenv/model_test.go b/cmd/dotenv/model_test.go index c7b1a5b9f..2b5d11115 100644 --- a/cmd/dotenv/model_test.go +++ b/cmd/dotenv/model_test.go @@ -171,9 +171,7 @@ func (suite *DotenvModelTestSuite) FinalModel(tm *teatest.TestModel) Model { } fm, ok := finalModel.(Model) - if !ok { - suite.T().Error("Final model is not of type Model") - } + suite.Require().True(ok, "Final model is not of type Model") return fm } diff --git a/cmd/pipeline/cmd.go b/cmd/pipeline/cmd.go new file mode 100644 index 000000000..7eb787d95 --- /dev/null +++ b/cmd/pipeline/cmd.go @@ -0,0 +1,57 @@ +// 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/get" + "github.com/datarobot/cli/cmd/pipeline/graph" + "github.com/datarobot/cli/cmd/pipeline/list" + "github.com/datarobot/cli/cmd/pipeline/lock" + "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(), + ) + + return cmd +} diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go new file mode 100644 index 000000000..deba921ed --- /dev/null +++ b/cmd/pipeline/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 pipeline + +import ( + "testing" + + "github.com/datarobot/cli/internal/features" + "github.com/spf13/cobra" + "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_HasAlias(t *testing.T) { + cmd := Cmd() + + assert.Contains(t, cmd.Aliases, "pipelines") +} + +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_IsGroupOnly(t *testing.T) { + cmd := Cmd() + + assert.Nil(t, cmd.RunE, "pipeline is a group command and should not have a RunE") +} + +func TestCmd_HasExpectedSubcommands(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "get": false, + "list": false, + "update": false, + "delete": false, + "lock": false, + "version": false, + "graph": false, + } + + 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) + } +} + +func TestCmd_VersionHasSubcommands(t *testing.T) { + cmd := Cmd() + + var versionCmd *cobra.Command + for _, sub := range cmd.Commands() { + if sub.Name() == "version" { + versionCmd = sub + + break + } + } + + assert.NotNil(t, versionCmd, "version subcommand must be registered") + + want := map[string]bool{ + "get": false, + "list": false, + } + + for _, sub := range versionCmd.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + + for name, found := range want { + assert.True(t, found, "expected version 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..273e747b0 --- /dev/null +++ b/cmd/pipeline/create/cmd.go @@ -0,0 +1,105 @@ +// 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/datarobot/cli/internal/telemetry" + "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) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "mode": mode, + "output_format": string(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..fb8c1b635 --- /dev/null +++ b/cmd/pipeline/del/cmd.go @@ -0,0 +1,80 @@ +// 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/internal/telemetry" + "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 + }, + } + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": telemetry.FirstArg(args), + } + }) + + 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/get/cmd.go b/cmd/pipeline/get/cmd.go new file mode 100644 index 000000000..8d3ee9e8d --- /dev/null +++ b/cmd/pipeline/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/internal/telemetry" + "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) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": telemetry.FirstArg(args), + "output_format": string(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..d9cf1ca1a --- /dev/null +++ b/cmd/pipeline/graph/cmd.go @@ -0,0 +1,158 @@ +// 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/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "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) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "output_format": string(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..bfbda957d --- /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["pipeline"].(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/list/cmd.go b/cmd/pipeline/list/cmd.go new file mode 100644 index 000000000..050204265 --- /dev/null +++ b/cmd/pipeline/list/cmd.go @@ -0,0 +1,76 @@ +// 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/datarobot/cli/internal/telemetry" + "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) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "mode": mode, + "offset": offset, + "limit": limit, + "output_format": string(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..c4afd1db2 --- /dev/null +++ b/cmd/pipeline/lock/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 lock + +import ( + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var 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) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": telemetry.FirstArg(args), + "output_format": string(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/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..e3793b386 --- /dev/null +++ b/cmd/pipeline/update/cmd.go @@ -0,0 +1,100 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "errors" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + 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) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": telemetry.FirstArg(args), + "output_format": string(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..b15ea2e18 --- /dev/null +++ b/cmd/pipeline/version/cmd_test.go @@ -0,0 +1,54 @@ +// 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_BasicMetadata(t *testing.T) { + cmd := Cmd() + + assert.Equal(t, "version", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) +} + +func TestCmd_IsGroupOnly(t *testing.T) { + cmd := Cmd() + + assert.Nil(t, cmd.RunE, "version is a group command and should not have a RunE") +} + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "get": false, + "list": false, + } + + for _, sub := range cmd.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + + for verb, present := range want { + assert.True(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..b51720b98 --- /dev/null +++ b/cmd/pipeline/version/get/cmd.go @@ -0,0 +1,88 @@ +// 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/internal/telemetry" + "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 { + 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") + _ = cmd.MarkFlagRequired("pipeline") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "version": telemetry.FirstArg(args), + "output_format": string(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..c6b9d2262 --- /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..7e219ca9b --- /dev/null +++ b/cmd/pipeline/version/list/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 list + +import ( + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + 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 { + 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.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of versions to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "offset": offset, + "limit": limit, + "output_format": string(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..fbb487c5e --- /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/cmd/task/compose/cmd.go b/cmd/task/compose/cmd.go index c2754fdcf..2d72f6434 100644 --- a/cmd/task/compose/cmd.go +++ b/cmd/task/compose/cmd.go @@ -18,7 +18,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "strings" "github.com/datarobot/cli/internal/cli" @@ -37,7 +36,7 @@ var templatePath string func RunE(_ *cobra.Command, _ []string) error { taskfileName, ignoreTaskfile := detectExistingTaskfile() - discovery, err := createDiscovery(taskfileName) + discovery, err := task.NewDiscovery(taskfileName, templatePath) if err != nil { _, _ = fmt.Fprintln(os.Stderr, err) @@ -89,43 +88,6 @@ func RunE(_ *cobra.Command, _ []string) error { return nil } -func createDiscovery(taskfileName string) (*task.Discovery, error) { - // Check for .Taskfile.template in the root directory if no template specified - autoTemplatePath := ".Taskfile.template" - - if templatePath == "" { - if _, err := os.Stat(autoTemplatePath); err == nil { - templatePath = autoTemplatePath - fmt.Printf("Using auto-discovered template: %s\n", autoTemplatePath) - } - } - - // If template is specified or found, use compose mode - if templatePath != "" { - absPath, err := validateTemplatePath(templatePath) - if err != nil { - return nil, fmt.Errorf("invalid template: %w", err) - } - - return task.NewComposeDiscovery(taskfileName, absPath), nil - } - - return task.NewTaskDiscovery(taskfileName), nil -} - -func validateTemplatePath(path string) (string, error) { - absPath, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("resolving template path: %w", err) - } - - if _, err := os.Stat(absPath); os.IsNotExist(err) { - return "", fmt.Errorf("template file not found: %s", absPath) - } - - return absPath, nil -} - // detectExistingTaskfile checks for existing Taskfile.yaml or Taskfile.yml // and returns the name of the existing one, or defaults to Taskfile.yaml func detectExistingTaskfile() (inUse, notInUse string) { diff --git a/cmd/task/run/cmd.go b/cmd/task/run/cmd.go index f165c6da9..40f84d508 100644 --- a/cmd/task/run/cmd.go +++ b/cmd/task/run/cmd.go @@ -97,7 +97,13 @@ Examples: SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { binaryName := "task" - discovery := task.NewTaskDiscovery("Taskfile.gen.yaml") + + discovery, err := task.NewDiscovery("Taskfile.gen.yaml", "") + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + + return cli.ErrSilent + } rootTaskfile, err := discovery.Discover(opts.Dir, 2) if err != nil { diff --git a/docs/commands/README.md b/docs/commands/README.md index 8bcf2ada8..f91e74838 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,17 @@ dr │ ├── install Install a plugin │ ├── uninstall Uninstall a plugin │ └── update Update plugins +├── pipeline 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 └── self CLI utility commands ├── completion Shell completion │ ├── install Install completions interactively @@ -235,6 +247,16 @@ 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`). + - `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). + ## Getting help ```bash diff --git a/docs/commands/pipeline.md b/docs/commands/pipeline.md new file mode 100644 index 000000000..c72e9a0aa --- /dev/null +++ b/docs/commands/pipeline.md @@ -0,0 +1,318 @@ +# `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. + +## 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. + +> [!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. + +## 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 +``` + +## 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 locked) | Render the pipeline/task DAG. | + +## 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 +``` + +### `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 +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 + v2 READY 3.12 2026-04-28T12:24:54Z create_vector_database + v3 READY 3.12 2026-04-28T12:25:11Z create_vector_database +``` + +If the pipeline doesn't exist, `get` prints +`No pipeline found with id: ` and exits 0. + +### `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] +``` + +**Constraints:** + +- The pipeline name encoded in the uploaded file **must match** the pipeline's + existing name. +- Locked pipelines cannot be updated (API responds `409 Conflict`). + +**Flags:** + +- `--from-file ` — alternative to the positional file argument. +- `--output ` — emit machine-parseable JSON. + +### `delete` + +Delete a pipeline and all of its versions. + +```bash +dr pipeline delete +``` + +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. + +```bash +dr pipeline lock [flags] +``` + +**Flags:** + +- `--output ` — emit machine-parseable JSON. + +### `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] +``` + +### `graph` + +Display the pipeline/task DAG as either a JSON payload 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 --output json +``` + +## Shared flags + +### `--from-file` / positional file + +`pipeline create` and `pipeline update` accept the input file in two equivalent ways: + +```bash +dr pipeline create ./my_pipeline.py +dr pipeline create --from-file=./my_pipeline.py +``` + +### `--output` + +Every verb that produces a payload accepts `--output json` to emit the response struct as indented JSON. + +### 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: + +```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 +``` + +## 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 +``` + +## Error handling + +| Status | Cause | +|--------|--------------------------------------------------------------------------------| +| `400` | Invalid Python file or mismatched pipeline name. | +| `404` | The provided `` or version does not exist. | +| `409` | Tried to update a `locked` pipeline. | + +## See also + +- [Authentication](auth.md) — how `dr auth login` and `--skip-auth` + interact. +- [Configuration](../user-guide/configuration.md) — config file and + environment-variable precedence. +- [Feature gates](../development/feature-gates.md) — flipping + `DATAROBOT_CLI_FEATURE_PIPELINE` on and off. diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md new file mode 100644 index 000000000..9aa573918 --- /dev/null +++ b/docs/commands/pipelines-reference.md @@ -0,0 +1,102 @@ +# `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 `pipeline` 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`) 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; 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` | **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`. | + +--- + +## 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`. | + +--- + +## Shared flag semantics + +### `--scope` / `--version` (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=` | + +### `--from-file` / positional file (create + update verbs) + +`pipeline create` and `pipeline update` 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. + +### `--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)`. + +### `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` (locked) | diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fef2e1c5d..2aad1bc4b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -70,6 +70,7 @@ nav: - completion: commands/completion.md - self: commands/self.md - plugins: commands/plugins.md + - pipeline: commands/pipeline.md - component: commands/component-managed-updates.md - Development: - development/README.md diff --git a/docs/plugins/assist/assist-0.1.23.tar.xz b/docs/plugins/assist/assist-0.1.23.tar.xz new file mode 100644 index 000000000..e2c99eedc Binary files /dev/null and b/docs/plugins/assist/assist-0.1.23.tar.xz differ diff --git a/docs/plugins/index.json b/docs/plugins/index.json index 3d58b9234..cdc1c56b7 100644 --- a/docs/plugins/index.json +++ b/docs/plugins/index.json @@ -5,6 +5,12 @@ "name": "assist", "description": "AI agent design, coding, and deployment assistant", "versions": [ + { + "version": "0.1.23", + "url": "assist/assist-0.1.23.tar.xz", + "sha256": "a1c7d30fd184565bb6219daffbc811a35939e6d65e6efa2b660e46f82b14d4a2", + "releaseDate": "2026-05-28" + }, { "version": "0.1.21", "url": "assist/assist-0.1.21.tar.xz", @@ -96,6 +102,18 @@ "releaseDate": "2026-02-06" } ] + }, + "xp": { + "name": "xp", + "description": "A local experimentation dashboard for DataRobot users to visualize and compare agent runs.", + "versions": [ + { + "version": "1.0.0", + "url": "xp/xp-1.0.0.tar.xz", + "sha256": "5035e784ca107ff29a16295790bf12fc6e28b2031721e8b95760757c723fc3b3", + "releaseDate": "2026-05-27" + } + ] } } } diff --git a/docs/plugins/xp/xp-1.0.0.tar.xz b/docs/plugins/xp/xp-1.0.0.tar.xz new file mode 100644 index 000000000..16de6e95f Binary files /dev/null and b/docs/plugins/xp/xp-1.0.0.tar.xz differ diff --git a/go.sum b/go.sum index 87a207685..32cf674c1 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 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/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/pipeline.go b/internal/pipeline/pipeline.go new file mode 100644 index 000000000..f686005d5 --- /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/pipelines. +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/pipelines. +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/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..b8a051bab --- /dev/null +++ b/internal/pipeline/transport_test.go @@ -0,0 +1,211 @@ +// 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) + }) +} + +// installEndpoint temporarily sets the DataRobot URL viper key to url, +// restoring the previous value at test cleanup. +func installEndpoint(t *testing.T, url string) { + t.Helper() + + prev := viperx.GetString(config.DataRobotURL) + + viperx.Set(config.DataRobotURL, url) + + t.Cleanup(func() { + viperx.Set(config.DataRobotURL, prev) + }) +} + +func TestBuildJSONRequest_BodyAndHeaders(t *testing.T) { + installSkipAuth(t) + + 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) +} diff --git a/internal/task/discovery.go b/internal/task/discovery.go index 7e59d695b..7008016c0 100644 --- a/internal/task/discovery.go +++ b/internal/task/discovery.go @@ -112,6 +112,27 @@ func NewComposeDiscovery(rootTaskfileName string, templatePath string) *Discover } } +// NewDiscovery creates the appropriate Discovery for the given taskfile name. +// If templatePath is non-empty it is resolved to an absolute path and used as +// the custom template. If templatePath is empty, Discover will automatically +// check for a ".Taskfile.template" file in the project root at runtime. +func NewDiscovery(taskfileName, templatePath string) (*Discovery, error) { + if templatePath == "" { + return NewTaskDiscovery(taskfileName), nil + } + + absPath, err := filepath.Abs(templatePath) + if err != nil { + return nil, fmt.Errorf("resolving template path: %w", err) + } + + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return nil, fmt.Errorf("template file not found: %s", absPath) + } + + return NewComposeDiscovery(taskfileName, absPath), nil +} + func (d *Discovery) Discover(root string, maxDepth int) (string, error) { // Check if .env file exists in the root directory envPath := filepath.Join(root, ".datarobot") @@ -135,6 +156,14 @@ func (d *Discovery) Discover(root string, maxDepth int) (string, error) { rootTaskfilePath := filepath.Join(root, d.RootTaskfileName) + // Auto-detect .Taskfile.template in the project root when no explicit template is configured + if d.TemplatePath == "" { + candidate := filepath.Join(root, ".Taskfile.template") + if _, statErr := os.Stat(candidate); statErr == nil { + d.TemplatePath = candidate + } + } + composeData, err := d.buildComposeData(root, includes) if err != nil { return "", fmt.Errorf("failed to build compose data: %w", err)