diff --git a/.github/workflows/.deps-install-smoke-tests-matrix.yaml b/.github/workflows/.deps-install-smoke-tests-matrix.yaml new file mode 100644 index 000000000..e66028ab2 --- /dev/null +++ b/.github/workflows/.deps-install-smoke-tests-matrix.yaml @@ -0,0 +1,42 @@ +name: Dependency Install Smoke Tests Matrix + +on: + workflow_call: + +jobs: + deps-install-smoke-tests: + strategy: + matrix: + sys: + - { os: ubuntu-latest, shell: bash } + - { os: macos-latest, shell: "zsh {0}" } + defaults: + run: + shell: ${{ matrix.sys.shell }} + runs-on: ${{ matrix.sys.os }} + name: Dependency Install Smoke Tests (${{ matrix.sys.os }}) + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build binary + run: task build + + - name: Run dependency install smoke tests + run: | + DR_BIN=./dist/dr bash smoke_test_scripts/run_deps_install_smoke_test.sh diff --git a/.github/workflows/.install-integration-tests-matrix.yaml b/.github/workflows/.install-integration-tests-matrix.yaml new file mode 100644 index 000000000..b40bcf7d1 --- /dev/null +++ b/.github/workflows/.install-integration-tests-matrix.yaml @@ -0,0 +1,37 @@ +name: Install Integration Tests Matrix + +on: + workflow_call: + +jobs: + install-integration-tests: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + name: Install Integration Tests (${{ matrix.os }}) + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build binary + run: task build + + - name: Run install integration tests + run: | + LOCAL_BINARY=./dist/dr bash smoke_test_scripts/run_install_integration_test.sh diff --git a/.github/workflows/.installation-tests-matrix.yaml b/.github/workflows/.installation-tests-matrix.yaml index dc11fcc26..0b8e64ad0 100644 --- a/.github/workflows/.installation-tests-matrix.yaml +++ b/.github/workflows/.installation-tests-matrix.yaml @@ -21,6 +21,9 @@ jobs: os: ${{ fromJSON(inputs.os-matrix) }} runs-on: ${{ matrix.os }} name: Installation Test (${{ matrix.os }}) + timeout-minutes: 30 + permissions: + contents: read steps: - name: Install for Linux and macOS (latest) if: matrix.os != 'windows-latest' && inputs.version == '' diff --git a/.github/workflows/.self-update-tests-matrix.yaml b/.github/workflows/.self-update-tests-matrix.yaml index 41a109ef6..ed9727c67 100644 --- a/.github/workflows/.self-update-tests-matrix.yaml +++ b/.github/workflows/.self-update-tests-matrix.yaml @@ -20,6 +20,9 @@ jobs: shell: ${{ matrix.sys.shell }} runs-on: ${{ matrix.sys.os }} name: Self-Update Smoke Tests (${{ matrix.sys.os }}) + timeout-minutes: 30 + permissions: + contents: read env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index cb867b6ea..cba9be0bd 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -22,6 +22,8 @@ jobs: templates: ${{ steps.changes.outputs.templates }} auth: ${{ steps.changes.outputs.auth }} go_code: ${{ steps.changes.outputs.go_code }} + deps: ${{ steps.changes.outputs.deps }} + install_sh: ${{ steps.changes.outputs.install_sh }} steps: - name: Checkout Code uses: actions/checkout@v4 @@ -44,6 +46,14 @@ jobs: - '**/*.go' - 'go.mod' - 'go.sum' + deps: + - 'cmd/dependencies/**' + - 'internal/dependencies/**' + - 'internal/tools/**' + - 'smoke_test_scripts/run_deps_install_smoke_test.sh' + install_sh: + - 'install.sh' + - 'smoke_test_scripts/run_install_integration_test.sh' lint: needs: detect-changes @@ -221,6 +231,18 @@ jobs: body: '๐Ÿ‘‹ **Maintainer reminder:** Smoke tests cannot run automatically on dependabot PRs due to GitHub secret restrictions.\n\nBefore merging, add the `run-smoke-tests` label and wait for the smoke tests to pass.' }); + deps-install-smoke-tests: + needs: detect-changes + if: needs.detect-changes.outputs.deps == 'true' + uses: ./.github/workflows/.deps-install-smoke-tests-matrix.yaml + secrets: inherit + + install-integration-tests: + needs: detect-changes + if: needs.detect-changes.outputs.install_sh == 'true' + uses: ./.github/workflows/.install-integration-tests-matrix.yaml + secrets: inherit + completion-tests: needs: [build, detect-changes] if: needs.detect-changes.outputs.completion == 'true' diff --git a/.github/workflows/deps-install-smoke-tests-on-demand.yaml b/.github/workflows/deps-install-smoke-tests-on-demand.yaml new file mode 100644 index 000000000..7349c9872 --- /dev/null +++ b/.github/workflows/deps-install-smoke-tests-on-demand.yaml @@ -0,0 +1,9 @@ +name: On-Demand Dependency Install Smoke Tests + +on: + workflow_dispatch: + +jobs: + deps-install-smoke-tests: + uses: ./.github/workflows/.deps-install-smoke-tests-matrix.yaml + secrets: inherit diff --git a/.github/workflows/install-integration-tests-on-demand.yaml b/.github/workflows/install-integration-tests-on-demand.yaml new file mode 100644 index 000000000..fb31da279 --- /dev/null +++ b/.github/workflows/install-integration-tests-on-demand.yaml @@ -0,0 +1,9 @@ +name: On-Demand Install Integration Tests + +on: + workflow_dispatch: + +jobs: + install-integration-tests: + uses: ./.github/workflows/.install-integration-tests-matrix.yaml + secrets: inherit diff --git a/cmd/pipeline/cmd.go b/cmd/pipeline/cmd.go index 7eb787d95..0ed096c58 100644 --- a/cmd/pipeline/cmd.go +++ b/cmd/pipeline/cmd.go @@ -19,8 +19,10 @@ import ( "github.com/datarobot/cli/cmd/pipeline/del" "github.com/datarobot/cli/cmd/pipeline/get" "github.com/datarobot/cli/cmd/pipeline/graph" + "github.com/datarobot/cli/cmd/pipeline/input" "github.com/datarobot/cli/cmd/pipeline/list" "github.com/datarobot/cli/cmd/pipeline/lock" + "github.com/datarobot/cli/cmd/pipeline/run" "github.com/datarobot/cli/cmd/pipeline/update" "github.com/datarobot/cli/cmd/pipeline/version" "github.com/datarobot/cli/internal/features" @@ -51,6 +53,8 @@ input payloads, runs, and recurring schedules.`, lock.Cmd(), version.Cmd(), graph.Cmd(), + run.Cmd(), + input.Cmd(), ) return cmd diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go index bf4e5c022..586370e20 100644 --- a/cmd/pipeline/cmd_test.go +++ b/cmd/pipeline/cmd_test.go @@ -63,6 +63,8 @@ func TestCmd_HasExpectedSubcommands(t *testing.T) { "lock": false, "version": false, "graph": false, + "run": false, + "input": false, } for _, sub := range cmd.Commands() { diff --git a/cmd/pipeline/input/cmd.go b/cmd/pipeline/input/cmd.go new file mode 100644 index 000000000..f2e6955b0 --- /dev/null +++ b/cmd/pipeline/input/cmd.go @@ -0,0 +1,51 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package input + +import ( + "github.com/datarobot/cli/cmd/pipeline/input/create" + "github.com/datarobot/cli/cmd/pipeline/input/del" + "github.com/datarobot/cli/cmd/pipeline/input/get" + "github.com/datarobot/cli/cmd/pipeline/input/list" + "github.com/datarobot/cli/cmd/pipeline/input/update" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline input`. It groups the +// CRUD verbs that operate on pipeline input sets. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "input", + Short: "Manage pipeline input sets", + Long: `Manage input payloads bound to a pipeline. + +Inputs come in two scopes: + - draft : mutable; bound to the current draft of a pipeline + - locked : immutable; bound to a specific frozen version + +When --version is supplied, the locked scope is selected automatically. +Pass --scope=draft to be explicit.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + update.Cmd(), + del.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/input/cmd_test.go b/cmd/pipeline/input/cmd_test.go new file mode 100644 index 000000000..4b7923a46 --- /dev/null +++ b/cmd/pipeline/input/cmd_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package input + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "update": false, + "delete": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipeline/input/create/cmd.go b/cmd/pipeline/input/create/cmd.go new file mode 100644 index 000000000..26e688a6d --- /dev/null +++ b/cmd/pipeline/input/create/cmd.go @@ -0,0 +1,95 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "errors" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + fromFile string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "create []", + Short: "Create a pipeline input set", + Long: `Create an input payload for a pipeline. + +The payload must be a JSON object. The path to the JSON file can be +supplied either as a positional argument or via --from-file=. +Exactly one of the two must be provided. + +Scope is selected from the --scope/--version flags: + - no flags -> draft + - --version=N -> locked, version N (scope auto-set) + - --scope=draft -> draft + - --scope=locked --version=N -> locked, version N + +Example: + dr pipeline input create --pipeline ./payload.json + dr pipeline input create --pipeline --from-file=./payload.json + dr pipeline input create --pipeline --version=2 ./payload.json --output-format json`, + Args: cobra.MaximumNArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + payload, err := pipeline.ResolvePayload(args, fromFile) + if err != nil { + return err + } + + result, err := pipeline.CreateInput(flags.PipelineID, scope, version, payload) + if err != nil { + return err + } + + return pipeline.RenderInput(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/input/create/cmd_test.go b/cmd/pipeline/input/create/cmd_test.go new file mode 100644 index 000000000..3fa401fb1 --- /dev/null +++ b/cmd/pipeline/input/create/cmd_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "draft", "--version", "2", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestCmd_RejectsMissingPayload(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "from-file", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/input/del/cmd.go b/cmd/pipeline/input/del/cmd.go new file mode 100644 index 000000000..9e051c06a --- /dev/null +++ b/cmd/pipeline/input/del/cmd.go @@ -0,0 +1,94 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package del implements the `dr pipeline input delete` verb. The +// directory is named `del` rather than `delete` because the latter +// shadows Go's built-in delete() function in importing files. + +package del + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var flags scopeflag.Flags + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline input set", + Long: `Delete an input payload from a pipeline. + +Example: + dr pipeline input delete --pipeline + dr pipeline input delete --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + err = pipeline.DeleteInput(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleDeleteError(err, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted input: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "input_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + } + }) + + return cmd +} + +// handleDeleteError converts a 404 into a friendly informational message +// (returns nil) so the user does not see a stack-trace-style HTTP error +// for what is effectively a no-op. +func handleDeleteError(err error, id string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No input found with id: " + id)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/input/del/cmd_test.go b/cmd/pipeline/input/del/cmd_test.go new file mode 100644 index 000000000..3c6a1ffe8 --- /dev/null +++ b/cmd/pipeline/input/del/cmd_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "delete", Cmd().Name()) +} diff --git a/cmd/pipeline/input/get/cmd.go b/cmd/pipeline/input/get/cmd.go new file mode 100644 index 000000000..bc28f872c --- /dev/null +++ b/cmd/pipeline/input/get/cmd.go @@ -0,0 +1,94 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline input set", + Long: `Display the full payload and metadata for a single input set. + +Example: + dr pipeline input get --pipeline + dr pipeline input get --pipeline --version=2 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.GetInput(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + return pipeline.RenderInput(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "input_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} + +func handleGetError(err error, inputID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No input found with id: " + inputID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/input/get/cmd_test.go b/cmd/pipeline/input/get/cmd_test.go new file mode 100644 index 000000000..c0e25f65b --- /dev/null +++ b/cmd/pipeline/input/get/cmd_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleGetError(httpErr, "in-1")) +} + +func TestHandleGetError_PropagatesOther(t *testing.T) { + err := handleGetError(errors.New("boom"), "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipeline/input/list/cmd.go b/cmd/pipeline/input/list/cmd.go new file mode 100644 index 000000000..0140dddbd --- /dev/null +++ b/cmd/pipeline/input/list/cmd.go @@ -0,0 +1,84 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline input sets", + Long: `List input payloads for a pipeline. + +Scope is selected the same way as create: + - no flags -> draft + - --version=N -> locked, version N (scope auto-set) + - --scope=draft -> draft + - --scope=locked --version=N -> locked, version N + +Example: + dr pipeline input list --pipeline + dr pipeline input list --pipeline --version=2 + dr pipeline input list --pipeline --offset 50 --limit 10 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + items, err := pipeline.ListInputs(flags.PipelineID, scope, version, offset, limit) + if err != nil { + return err + } + + return pipeline.RenderInputs(outputFormat, items) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of inputs to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "offset": offset, + "limit": limit, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/input/list/cmd_test.go b/cmd/pipeline/input/list/cmd_test.go new file mode 100644 index 000000000..c39ace176 --- /dev/null +++ b/cmd/pipeline/input/list/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t) + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/input/update/cmd.go b/cmd/pipeline/input/update/cmd.go new file mode 100644 index 000000000..1351f7836 --- /dev/null +++ b/cmd/pipeline/input/update/cmd.go @@ -0,0 +1,77 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + fromFile string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "update []", + Short: "Update a draft pipeline input set", + Long: `Update the payload of a draft input set. + +Locked inputs are immutable; the API will return 409 if you try to update +one. The new payload must be a JSON object supplied either as a positional +argument or via --from-file=. + +Example: + dr pipeline input update --pipeline ./new_payload.json + dr pipeline input update --pipeline --from-file=./new_payload.json --output-format json`, + Args: cobra.RangeArgs(1, 2), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + inputID := args[0] + + payload, err := pipeline.ResolvePayload(args[1:], fromFile) + if err != nil { + return err + } + + result, err := pipeline.UpdateInput(pipelineID, inputID, payload) + if err != nil { + return err + } + + return pipeline.RenderInput(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "input_id": telemetry.FirstArg(args), + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/input/update/cmd_test.go b/cmd/pipeline/input/update/cmd_test.go new file mode 100644 index 000000000..8e7bd2584 --- /dev/null +++ b/cmd/pipeline/input/update/cmd_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "in-1", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "in-1", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RequiresInputID(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_RejectsMissingPayload(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "from-file", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/run/cancel/cmd.go b/cmd/pipeline/run/cancel/cmd.go new file mode 100644 index 000000000..363a4c553 --- /dev/null +++ b/cmd/pipeline/run/cancel/cmd.go @@ -0,0 +1,81 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cancel + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipeline/run/runutil" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var flags scopeflag.Flags + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a pipeline run", + Long: `Request cancellation of an in-flight run. + +The API rejects cancellation if the run has already reached a terminal +state (COMPLETED, FAILED, CANCELLED). + +Example: + dr pipeline run cancel --pipeline + dr pipeline run cancel --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + err = pipeline.CancelRun(flags.PipelineID, scope, version, args[0]) + if err != nil { + return runutil.HandleRunNotFoundError(err, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render("Cancelled run: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "run_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + } + }) + + return cmd +} diff --git a/cmd/pipeline/run/cancel/cmd_test.go b/cmd/pipeline/run/cancel/cmd_test.go new file mode 100644 index 000000000..ac74b6bd9 --- /dev/null +++ b/cmd/pipeline/run/cancel/cmd_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cancel + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "cancel", Cmd().Name()) +} diff --git a/cmd/pipeline/run/cmd.go b/cmd/pipeline/run/cmd.go new file mode 100644 index 000000000..bd2bc50da --- /dev/null +++ b/cmd/pipeline/run/cmd.go @@ -0,0 +1,49 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package run + +import ( + "github.com/datarobot/cli/cmd/pipeline/run/cancel" + "github.com/datarobot/cli/cmd/pipeline/run/create" + "github.com/datarobot/cli/cmd/pipeline/run/get" + "github.com/datarobot/cli/cmd/pipeline/run/list" + "github.com/datarobot/cli/cmd/pipeline/run/status" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline run`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "Manage pipeline runs", + Long: `Trigger and inspect runs (single executions) of a pipeline. + +Runs come in two scopes: + - draft : executes against the in-flight draft of a pipeline + - locked : executes against a specific frozen version + +When --version is supplied, the locked scope is selected automatically.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + status.Cmd(), + cancel.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/run/cmd_test.go b/cmd/pipeline/run/cmd_test.go new file mode 100644 index 000000000..ce2c30cff --- /dev/null +++ b/cmd/pipeline/run/cmd_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package run + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "status": false, + "cancel": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipeline/run/create/cmd.go b/cmd/pipeline/run/create/cmd.go new file mode 100644 index 000000000..d452e1772 --- /dev/null +++ b/cmd/pipeline/run/create/cmd.go @@ -0,0 +1,77 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + inputID string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Trigger a pipeline run", + Long: `Trigger a new run (single execution) of a pipeline. + +The run is created in PENDING state. Use ` + "`dr pipeline run get`" + ` +or ` + "`dr pipeline run status`" + ` to follow its progress. + +Example: + dr pipeline run create --pipeline --input + dr pipeline run create --pipeline --version=2 --input --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.CreateRun(flags.PipelineID, scope, version, inputID) + if err != nil { + return err + } + + return pipeline.RenderRun(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") + _ = cmd.MarkFlagRequired("input") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/run/create/cmd_test.go b/cmd/pipeline/run/create/cmd_test.go new file mode 100644 index 000000000..1b65852bb --- /dev/null +++ b/cmd/pipeline/run/create/cmd_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsMissingInput(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "input") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--scope", "draft", "--version", "2") + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "input", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/run/get/cmd.go b/cmd/pipeline/run/get/cmd.go new file mode 100644 index 000000000..cc2599dab --- /dev/null +++ b/cmd/pipeline/run/get/cmd.go @@ -0,0 +1,79 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + + "github.com/datarobot/cli/cmd/pipeline/run/runutil" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline run", + Long: `Display the full record for a single run. + +Example: + dr pipeline run get --pipeline + dr pipeline run get --pipeline --version=2 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.GetRun(flags.PipelineID, scope, version, args[0]) + if err != nil { + return runutil.HandleRunNotFoundError(err, args[0]) + } + + return pipeline.RenderRun(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "run_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/run/get/cmd_test.go b/cmd/pipeline/run/get/cmd_test.go new file mode 100644 index 000000000..caf3492b6 --- /dev/null +++ b/cmd/pipeline/run/get/cmd_test.go @@ -0,0 +1,52 @@ +// 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 ( + "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", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} diff --git a/cmd/pipeline/run/list/cmd.go b/cmd/pipeline/run/list/cmd.go new file mode 100644 index 000000000..c67178072 --- /dev/null +++ b/cmd/pipeline/run/list/cmd.go @@ -0,0 +1,77 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline runs", + Long: `List runs for a pipeline. + +Example: + dr pipeline run list --pipeline + dr pipeline run list --pipeline --version=2 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + items, err := pipeline.ListRuns(flags.PipelineID, scope, version, offset, limit) + if err != nil { + return err + } + + return pipeline.RenderRuns(outputFormat, items) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of runs to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "offset": offset, + "limit": limit, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/run/list/cmd_test.go b/cmd/pipeline/run/list/cmd_test.go new file mode 100644 index 000000000..c39ace176 --- /dev/null +++ b/cmd/pipeline/run/list/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t) + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/run/runutil/errors.go b/cmd/pipeline/run/runutil/errors.go new file mode 100644 index 000000000..84c888957 --- /dev/null +++ b/cmd/pipeline/run/runutil/errors.go @@ -0,0 +1,38 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runutil + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/tui" +) + +// HandleRunNotFoundError converts a 404 into a friendly informational message +// (returns nil) so the caller does not surface a raw HTTP error for a no-op. +func HandleRunNotFoundError(err error, runID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No run found with id: " + runID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/run/runutil/errors_test.go b/cmd/pipeline/run/runutil/errors_test.go new file mode 100644 index 000000000..4ef7f0b77 --- /dev/null +++ b/cmd/pipeline/run/runutil/errors_test.go @@ -0,0 +1,36 @@ +// 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 runutil + +import ( + "errors" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandleRunNotFoundError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, HandleRunNotFoundError(httpErr, "d-1")) +} + +func TestHandleRunNotFoundError_PropagatesOther(t *testing.T) { + err := HandleRunNotFoundError(errors.New("boom"), "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipeline/run/status/cmd.go b/cmd/pipeline/run/status/cmd.go new file mode 100644 index 000000000..90667654b --- /dev/null +++ b/cmd/pipeline/run/status/cmd.go @@ -0,0 +1,79 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "errors" + + "github.com/datarobot/cli/cmd/pipeline/run/runutil" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "status ", + Short: "Get the lightweight status of a pipeline run", + Long: `Poll a run's current status without re-downloading the full record. + +Example: + dr pipeline run status --pipeline + dr pipeline run status --pipeline --version=2 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.GetRunStatus(flags.PipelineID, scope, version, args[0]) + if err != nil { + return runutil.HandleRunNotFoundError(err, args[0]) + } + + return pipeline.RenderRunStatus(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "run_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/run/status/cmd_test.go b/cmd/pipeline/run/status/cmd_test.go new file mode 100644 index 000000000..d329515ab --- /dev/null +++ b/cmd/pipeline/run/status/cmd_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "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", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} diff --git a/cmd/root.go b/cmd/root.go index 420220416..c1045fc97 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -224,6 +224,10 @@ func init() { // Discover and register plugin commands plugin.RegisterPluginCommands(RootCmd.Command) + // Guard parent commands against unrecognised positional args. + // Must run after all commands (including plugins) are registered. + setUnknownArgGuards(RootCmd.Command) + // Override the default help command to add --all-commands flag defaultHelpFunc := RootCmd.HelpFunc() @@ -247,6 +251,38 @@ func init() { }) } +// setUnknownArgGuards walks the command tree and installs a RunE + Args validator +// on every pure-parent command (has subcommands, no Run/RunE, no explicit Args set). +// +// Cobra checks !Runnable() before ValidateArgs, so a non-runnable parent silently +// shows help for any arg. Making the command runnable lets Args fire first and +// return a clear error; the RunE fallback shows help when no args are given. +func setUnknownArgGuards(root *cobra.Command) { + for _, cmd := range root.Commands() { + setUnknownArgGuards(cmd) + } + + if !root.HasSubCommands() { + return + } + + if root.Args != nil || root.RunE != nil || root.Run != nil { + return + } + + root.Args = func(_ *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + + return fmt.Errorf("unknown command: %s", args[0]) + } + + root.RunE = func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + } +} + // initializeConfig initializes the configuration by reading from // various sources such as environment variables and config files. func initializeConfig(_ *cobra.Command) error { diff --git a/cmd/root_test.go b/cmd/root_test.go index ac3d15895..ca901ef3f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -20,6 +20,7 @@ import ( "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/internal/tools" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -93,6 +94,13 @@ func TestVersionFlag(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // pflag does not reset flag values between Parse calls, so the + // version flag stays true after this test. Reset it so subsequent + // tests that execute the root command aren't affected. + t.Cleanup(func() { + _ = RootCmd.PersistentFlags().Set("version", "false") + }) + cmd := RootCmd buf := new(bytes.Buffer) cmd.SetOut(buf) @@ -173,6 +181,144 @@ func TestTelemetryPropExtractor_OnErrorPath(t *testing.T) { "PropExtractor must see missing_deps populated by RunE even on the error path") } +func TestUnknownArgGuard(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + errContains string + }{ + { + name: "valid subcommand passes", + args: []string{"self", "version"}, + wantErr: false, + }, + { + name: "unknown subcommand on parent command errors", + args: []string{"self", "not-a-thing"}, + wantErr: true, + errContains: "unknown command: not-a-thing", + }, + { + name: "parent command with no args shows help without error", + args: []string{"self"}, + wantErr: false, + }, + { + name: "unknown top-level command errors", + args: []string{"not-a-command"}, + wantErr: true, + errContains: "unknown command: not-a-command", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := RootCmd + + var buf bytes.Buffer + + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(tt.args) + + err := cmd.Execute() + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestSetUnknownArgGuards_AppliesGuardToPureParent(t *testing.T) { + child := &cobra.Command{ + Use: "child", + RunE: func(_ *cobra.Command, _ []string) error { return nil }, + } + parent := &cobra.Command{Use: "parent"} + + parent.AddCommand(child) + + setUnknownArgGuards(parent) + + require.NotNil(t, parent.Args, "pure parent command should have Args guard installed") + require.NotNil(t, parent.RunE, "pure parent command should have RunE installed") + + err := parent.Args(parent, []string{"bogus"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown command: bogus") + assert.NoError(t, parent.Args(parent, []string{})) +} + +func TestSetUnknownArgGuards_SkipsCommandWithRunE(t *testing.T) { + child := &cobra.Command{ + Use: "child", + RunE: func(_ *cobra.Command, _ []string) error { return nil }, + } + parent := &cobra.Command{ + Use: "parent", + RunE: func(_ *cobra.Command, _ []string) error { return nil }, + } + + parent.AddCommand(child) + + setUnknownArgGuards(parent) + + assert.Nil(t, parent.Args, "parent with RunE should not have Args guard installed") +} + +// TestSetUnknownArgGuards_RootLevelUnknownCommand verifies that a root-like +// command (pure parent, no RunE) rejects an unrecognised first arg with the +// expected "unknown command" message. +// +// Uses a fresh, isolated command tree rather than the global RootCmd to avoid +// state pollution from cobra's package-level finalizers slice, which accumulates +// across Execute calls in other tests and can cause RootCmd to appear non-runnable +// by the time this assertion runs in the full test suite. +func TestSetUnknownArgGuards_RootLevelUnknownCommand(t *testing.T) { + child := &cobra.Command{ + Use: "child", + RunE: func(_ *cobra.Command, _ []string) error { return nil }, + } + root := &cobra.Command{Use: "root"} + + root.AddCommand(child) + + setUnknownArgGuards(root) + + require.NotNil(t, root.Args) + + err := root.Args(root, []string{"not-a-command"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown command: not-a-command") +} + +func TestSetUnknownArgGuards_SkipsExplicitArgs(t *testing.T) { + child := &cobra.Command{ + Use: "child", + RunE: func(_ *cobra.Command, _ []string) error { return nil }, + } + parent := &cobra.Command{ + Use: "parent", + Args: cobra.NoArgs, + } + + parent.AddCommand(child) + + setUnknownArgGuards(parent) + + err := parent.Args(parent, []string{"x"}) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "unknown command:", "explicit Args validator should not be overridden") +} + func TestWorkloadCommandNotPresentByDefault(t *testing.T) { // Verify that workload command is not present by default (feature not enabled). // The feature gating happens during init(), so this tests the actual state. diff --git a/cmd/templates/setup/model.go b/cmd/templates/setup/model.go index 34ddc2774..f864193b4 100644 --- a/cmd/templates/setup/model.go +++ b/cmd/templates/setup/model.go @@ -437,6 +437,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint: cyclop case templateInDirMsg: m.screen = dotenvScreen m.list.Template = msg.template + m.template = msg.template m.dotenv.DotenvFile = msg.dotenvFile m.dotenv.NeedsPulumiLogin, m.dotenv.PulumiAlreadyLoggedIn, m.dotenv.NeedsPulumiPassphrase = dotenv.CheckPulumiSetup(filepath.Dir(msg.dotenvFile), nil) m.dotenvSetupCompleted = true @@ -466,7 +467,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint: cyclop } // Update state for templates setup completion - _ = state.UpdateAfterTemplatesSetup(repoRoot) + _ = state.UpdateAfterTemplatesSetup(repoRoot, m.template.Name, m.template.ID) return m, exit case exitMsg: diff --git a/docs/commands/README.md b/docs/commands/README.md index f91e74838..f2e7be109 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -85,7 +85,19 @@ dr โ”‚ โ”œโ”€โ”€ 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 +โ”‚ โ”œโ”€โ”€ graph Display the pipeline/task DAG of a pipeline +โ”‚ โ”œโ”€โ”€ run Trigger and inspect pipeline executions +โ”‚ โ”‚ โ”œโ”€โ”€ create Trigger a run from an input +โ”‚ โ”‚ โ”œโ”€โ”€ list List runs for a pipeline +โ”‚ โ”‚ โ”œโ”€โ”€ get Display a single run +โ”‚ โ”‚ โ”œโ”€โ”€ status Lightweight run status (for polling) +โ”‚ โ”‚ โ””โ”€โ”€ cancel Cancel a running run +โ”‚ โ””โ”€โ”€ input Manage pipeline input payloads +โ”‚ โ”œโ”€โ”€ create Register a JSON payload on a pipeline +โ”‚ โ”œโ”€โ”€ list List inputs for a pipeline (draft or locked scope) +โ”‚ โ”œโ”€โ”€ get Display a single input +โ”‚ โ”œโ”€โ”€ update Update a draft input's payload +โ”‚ โ””โ”€โ”€ delete Delete an input โ””โ”€โ”€ self CLI utility commands โ”œโ”€โ”€ completion Shell completion โ”‚ โ”œโ”€โ”€ install Install completions interactively @@ -256,6 +268,8 @@ For detailed documentation on each command, see: - `lock`—promote a draft pipeline to locked mode. - `version`—`list` / `get` to inspect pipeline versions. - `graph`—display the pipeline/task DAG (draft or locked). + - `run`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. + - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by runs. ## Getting help diff --git a/docs/commands/pipeline.md b/docs/commands/pipeline.md index eff859b1f..332bda465 100644 --- a/docs/commands/pipeline.md +++ b/docs/commands/pipeline.md @@ -78,6 +78,8 @@ dr pipeline lock | `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. | +| `dr pipeline run โ€ฆ` | `โ€ฆ/dispatches` and `โ€ฆ/{id}` | Trigger, inspect, and cancel runs. | +| `dr pipeline input โ€ฆ` | `โ€ฆ/inputs` and `โ€ฆ/inputs/{input_id}` | Manage JSON payloads for runs. | ## Subcommands @@ -300,13 +302,46 @@ dr pipeline version get --pipeline 2 dr pipeline graph --pipeline --version=2 --output-format json ``` +### `input` + +Manage JSON payloads that drive a run. + +```bash +dr pipeline input create --pipeline # draft scope +dr pipeline input create --pipeline --version=N # locked scope +dr pipeline input list --pipeline [--scope|--version] [--offset N] [--limit N] +dr pipeline input get --pipeline [--scope|--version] +dr pipeline input update --pipeline # draft only +dr pipeline input delete --pipeline [--scope|--version] +``` + +The payload file must contain a JSON object. The CLI wraps it in `{"payload": โ€ฆ}` before sending. + +### `run` + +Trigger, inspect, and cancel pipeline executions. + +```bash +dr pipeline run create --pipeline --input # draft +dr pipeline run create --pipeline --version=N --input # locked +dr pipeline run list --pipeline [--scope|--version] +dr pipeline run get --pipeline [--scope|--version] +dr pipeline run status --pipeline [--scope|--version] +dr pipeline run cancel --pipeline [--scope|--version] +``` + +`run status` is a lighter-weight call intended for polling โ€” returns just +the run ID, status, and Covalent dispatch ID. + +`run cancel` returns `409 Conflict` if the run is already terminal. + ## 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. | +| `404` | The provided ``, version, or run does not exist. | +| `409` | Tried to update a `locked` pipeline, or cancel an already-terminal run. | ## See also diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index b783eab14..5d55b9520 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -45,7 +45,7 @@ accepts. ## Shared flag semantics -### `--scope` / `--version` (graph) +### `--scope` / `--version` (inputs, runs, graph) The CLI mirrors the API's two URL shapes โ€” `/pipelines/{id}/โ€ฆ` for the mutable draft and `/pipelines/{id}/versions/{ver}/โ€ฆ` for a locked @@ -86,6 +86,37 @@ exercising a local API stub that doesn't implement `/version/`. --- +## Inputs (`dr pipeline input โ€ฆ`) + +Inputs exist in two scopes โ€” **draft** and **locked** โ€” selected via `--scope` / `--version`. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline input create` | `POST /pipelines/{id}/inputs` (draft)
`POST /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipeline input create --pipeline ./payload.json`
`dr pipeline input create --pipeline --version=2 ./payload.json --output-format json` | **Positional:** `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--from-file=`, `--output-format json`. | +| `dr pipeline input list` | `GET /pipelines/{id}/inputs` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipeline input list --pipeline ` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output-format json`. | +| `dr pipeline input get` | `GET /pipelines/{id}/inputs/{input_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipeline input get --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipeline input update` | `PATCH /pipelines/{id}/inputs/{input_id}` (draft only) | `dr pipeline input update --pipeline ./payload.json` | **Positional:** `` (required), ``. **Flags:** `--pipeline ` (required), `--from-file=`, `--output-format json`. | +| `dr pipeline input delete` | `DELETE /pipelines/{id}/inputs/{input_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipeline input delete --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + + +## Runs (`dr pipeline run โ€ฆ`) + +Same draft/locked scope rules as graph. The wire-level URLs still use the legacy +term `dispatches` / `dispatch_id`, but the CLI's `--output-format json` remaps these to +`run_id` / `covalent_run_id`. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline run create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipeline run create --pipeline --input `
`dr pipeline run create --pipeline --version=2 --input --output-format json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipeline run list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipeline run list --pipeline `
`dr pipeline run list --pipeline --version=2 --output-format json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output-format json`. | +| `dr pipeline run get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipeline run get --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipeline run status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` (locked) | `dr pipeline run status --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipeline run cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipeline run cancel --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + ## Quick endpoint lookup | API endpoint | CLI command | @@ -100,3 +131,16 @@ exercising a local API stub that doesn't implement `/version/`. | `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) | +| `POST /pipelines/{id}/dispatches` | `dr pipeline run create` (draft) | +| `GET /pipelines/{id}/dispatches` | `dr pipeline run list` (draft) | +| `GET /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run get` (draft) | +| `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run cancel` (draft) | +| `POST /pipelines/{id}/inputs` | `dr pipeline input create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input create` (locked) | +| `GET /pipelines/{id}/inputs` | `dr pipeline input list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input list` (locked) | +| `GET /pipelines/{id}/inputs/{input_id}` | `dr pipeline input get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input get` (locked) | +| `PATCH /pipelines/{id}/inputs/{input_id}` | `dr pipeline input update` (draft) | +| `DELETE /pipelines/{id}/inputs/{input_id}` | `dr pipeline input delete` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input delete` (locked) | diff --git a/docs/plugins/assist/assist-0.1.24.tar.xz b/docs/plugins/assist/assist-0.1.24.tar.xz new file mode 100644 index 000000000..97cf4d176 Binary files /dev/null and b/docs/plugins/assist/assist-0.1.24.tar.xz differ diff --git a/docs/plugins/index.json b/docs/plugins/index.json index adcb2ca6c..733b13196 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.24", + "url": "assist/assist-0.1.24.tar.xz", + "sha256": "5d8e37c4d5f9f3f7900c01f4805d70817bbdf77c80e67701c64f3f28a0cea011", + "releaseDate": "2026-06-02" + }, { "version": "0.1.23", "url": "assist/assist-0.1.23.tar.xz", @@ -107,6 +113,12 @@ "name": "xp", "description": "A local experimentation dashboard for DataRobot users to visualize and compare agent runs.", "versions": [ + { + "version": "1.2.0", + "url": "xp/xp-1.2.0.tar.xz", + "sha256": "4e4923d5719a79b7501858b50b734bd05586e173a3dcd834fc32f201696bf1ee", + "releaseDate": "2026-06-02" + }, { "version": "1.1.1", "url": "xp/xp-1.1.1.tar.xz", diff --git a/docs/plugins/xp/xp-1.2.0.tar.xz b/docs/plugins/xp/xp-1.2.0.tar.xz new file mode 100644 index 000000000..bec605ca6 Binary files /dev/null and b/docs/plugins/xp/xp-1.2.0.tar.xz differ diff --git a/internal/pipeline/input.go b/internal/pipeline/input.go new file mode 100644 index 000000000..f01772ad5 --- /dev/null +++ b/internal/pipeline/input.go @@ -0,0 +1,153 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// input.go contains the typed client wrappers for the pipeline input +// endpoints described in pipelines-api/.../controllers/pipeline_input.py. +// Both draft and locked URL shapes are exercised through Scope/version. + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" + "time" +) + +// InputState mirrors PipelineInputState in the pipelines-api enums. +type InputState string + +const ( + InputStateValid InputState = "VALID" + InputStateInvalid InputState = "INVALID" +) + +// Input mirrors PipelineInputResponse from the pipelines-api. +type Input struct { + InputID string `json:"id"` + PipelineID string `json:"pipelineId"` + VersionID *int `json:"versionId,omitempty"` + IsDraft bool `json:"isDraft"` + Payload map[string]any `json:"payload"` + State InputState `json:"state"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// InputCreateRequest mirrors PipelineInputCreateRequest. +type InputCreateRequest struct { + Payload map[string]any `json:"payload"` +} + +// InputUpdateRequest mirrors PipelineInputUpdateRequest (draft-only). +type InputUpdateRequest struct { + Payload map[string]any `json:"payload"` +} + +// CreateInput POSTs a new input set against the appropriate URL for the +// given scope/version. +func CreateInput(pipelineID string, scope Scope, version *int, payload map[string]any) (*Input, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs") + if err != nil { + return nil, err + } + + body := InputCreateRequest{Payload: payload} + + var result Input + + err = doJSON(http.MethodPost, endpoint, body, "create input", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListInputs returns a paginated slice of inputs for the given scope. +func ListInputs(pipelineID string, scope Scope, version *int, offset, limit int) ([]Input, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var page DataPage[Input] + + err = doJSON(http.MethodGet, endpoint, nil, "inputs", &page) + if err != nil { + return nil, err + } + + return page.Data, nil +} + +// GetInput fetches a single input by id within the given scope. +func GetInput(pipelineID string, scope Scope, version *int, inputID string) (*Input, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs/"+inputID) + if err != nil { + return nil, err + } + + var input Input + + err = doJSON(http.MethodGet, endpoint, nil, "input", &input) + if err != nil { + return nil, err + } + + return &input, nil +} + +// UpdateInput PATCHes a draft input set with a new payload. Locked inputs +// cannot be updated; the API will return 409 in that case. +func UpdateInput(pipelineID, inputID string, payload map[string]any) (*Input, error) { + endpoint, err := EndpointFor(pipelineID, ScopeDraft, nil, "inputs/"+inputID) + if err != nil { + return nil, err + } + + body := InputUpdateRequest{Payload: payload} + + var result Input + + err = doJSON(http.MethodPatch, endpoint, body, "update input", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteInput removes an input set within the given scope. +func DeleteInput(pipelineID string, scope Scope, version *int, inputID string) error { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs/"+inputID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete input") +} diff --git a/internal/pipeline/input_output.go b/internal/pipeline/input_output.go new file mode 100644 index 000000000..6d8f9158b --- /dev/null +++ b/internal/pipeline/input_output.go @@ -0,0 +1,193 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// input_output.go centralises the human/JSON output rendering used by the +// input verbs so each verb file stays focused on flag wiring. +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strconv" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// inputJSON is the CLI-facing DTO used for `--output-format json`. +type inputJSON struct { + InputID string `json:"input_id"` + PipelineID string `json:"pipeline_id"` + Scope string `json:"scope"` + VersionID *int `json:"version_id,omitempty"` + State string `json:"state"` + Payload map[string]any `json:"payload"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toInputJSON(input Input) inputJSON { + scope := "draft" + + if input.VersionID != nil { + scope = "locked" + } + + return inputJSON{ + InputID: input.InputID, + PipelineID: input.PipelineID, + Scope: scope, + VersionID: input.VersionID, + State: string(input.State), + Payload: input.Payload, + CreatedAt: input.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: input.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderInput routes a single input to JSON or human output. +func RenderInput(format OutputFormat, input Input) error { + if format == OutputFormatJSON { + return PrintInputJSON(input) + } + + PrintInputHuman(input) + + return nil +} + +// RenderInputs routes a list of inputs to JSON or human output. +func RenderInputs(format OutputFormat, inputs []Input) error { + if format == OutputFormatJSON { + return PrintInputListJSON(inputs) + } + + PrintInputListHuman(inputs) + + return nil +} + +// PrintInputJSON marshals an input record as indented JSON through the DTO. +func PrintInputJSON(input Input) error { + data, err := json.MarshalIndent(toInputJSON(input), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintInputHuman renders the key facts about a single input record. +func PrintInputHuman(input Input) { + scope := "draft" + versionDisplay := emptyValuePlaceholder + + if input.VersionID != nil { + scope = "locked" + versionDisplay = strconv.Itoa(*input.VersionID) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Input ID:\t%s\n", input.InputID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", input.PipelineID) + fmt.Fprintf(w, "Scope:\t%s\n", scope) + fmt.Fprintf(w, "Version:\t%s\n", versionDisplay) + fmt.Fprintf(w, "State:\t%s\n", string(input.State)) + fmt.Fprintf(w, "Created:\t%s\n", input.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", input.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() + + payload, err := json.MarshalIndent(input.Payload, "", " ") + if err != nil { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Payload:")) + fmt.Println(string(payload)) +} + +// PrintInputListJSON marshals a list of inputs as indented JSON through the DTO. +func PrintInputListJSON(inputs []Input) error { + view := make([]inputJSON, len(inputs)) + + for i, in := range inputs { + view[i] = toInputJSON(in) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintInputListHuman renders a lipgloss table summary of inputs. +func PrintInputListHuman(inputs []Input) { + if len(inputs) == 0 { + fmt.Println(tui.DimStyle.Render("No inputs found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"INPUT ID", "SCOPE", "VERSION", "STATE", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, in := range inputs { + scope := "draft" + ver := emptyValuePlaceholder + + if in.VersionID != nil { + scope = "locked" + ver = strconv.Itoa(*in.VersionID) + } + + t.Row(in.InputID, scope, ver, string(in.State), in.UpdatedAt.UTC().Format(timestampFormat)) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} diff --git a/internal/pipeline/input_output_test.go b/internal/pipeline/input_output_test.go new file mode 100644 index 000000000..c92174541 --- /dev/null +++ b/internal/pipeline/input_output_test.go @@ -0,0 +1,141 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleInput() Input { + ver := 3 + + return Input{ + InputID: "in-1", + PipelineID: "p-1", + VersionID: &ver, + IsDraft: false, + Payload: map[string]any{"key": "value"}, + State: InputStateValid, + CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 29, 10, 5, 0, 0, time.UTC), + } +} + +// โ”€โ”€ toInputJSON remapping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestToInputJSON_RemapsWireFields(t *testing.T) { + j := toInputJSON(sampleInput()) + + assert.Equal(t, "in-1", j.InputID) + assert.Equal(t, "p-1", j.PipelineID) + assert.Equal(t, "locked", j.Scope) + require.NotNil(t, j.VersionID) + assert.Equal(t, 3, *j.VersionID) + assert.Equal(t, string(InputStateValid), j.State) + assert.Equal(t, map[string]any{"key": "value"}, j.Payload) +} + +func TestToInputJSON_DraftScope(t *testing.T) { + in := sampleInput() + in.VersionID = nil + + j := toInputJSON(in) + + assert.Equal(t, "draft", j.Scope) + assert.Nil(t, j.VersionID) +} + +func TestToInputJSON_FormatsTimestampsAsRFC3339(t *testing.T) { + j := toInputJSON(sampleInput()) + + assert.Equal(t, "2026-04-29T10:00:00Z", j.CreatedAt) + assert.Equal(t, "2026-04-29T10:05:00Z", j.UpdatedAt) +} + +func TestToInputJSON_JSONKeysUseCliVocabulary(t *testing.T) { + data, err := json.Marshal(toInputJSON(sampleInput())) + require.NoError(t, err) + + var raw map[string]any + + require.NoError(t, json.Unmarshal(data, &raw)) + + assert.Contains(t, raw, "input_id", "wire 'id' must be remapped to 'input_id'") + assert.Contains(t, raw, "pipeline_id") + assert.Contains(t, raw, "scope") + assert.Contains(t, raw, "version_id") + assert.NotContains(t, raw, "id", "raw wire key 'id' must not appear in CLI output") + assert.NotContains(t, raw, "pipelineId") + assert.NotContains(t, raw, "versionId") +} + +// โ”€โ”€ RenderInput โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestRenderInput_JSON(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, RenderInput(OutputFormatJSON, sampleInput())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + assert.Equal(t, "in-1", parsed["input_id"]) + assert.Equal(t, "locked", parsed["scope"]) + assert.Equal(t, "2026-04-29T10:00:00Z", parsed["created_at"]) +} + +func TestRenderInput_Human(t *testing.T) { + out := captureStdout(t, func() { PrintInputHuman(sampleInput()) }) + + assert.Contains(t, out, "in-1") + assert.Contains(t, out, "locked") + assert.Contains(t, out, string(InputStateValid)) +} + +// โ”€โ”€ PrintInputListJSON โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestPrintInputListJSON_RemapsFields(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, PrintInputListJSON([]Input{sampleInput()})) + }) + + var parsed []map[string]any + + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + require.Len(t, parsed, 1) + assert.Equal(t, "in-1", parsed[0]["input_id"]) + assert.Equal(t, "locked", parsed[0]["scope"]) +} + +// โ”€โ”€ PrintInputListHuman โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestPrintInputListHuman_Empty(t *testing.T) { + out := captureStdout(t, func() { PrintInputListHuman(nil) }) + assert.Contains(t, out, "No inputs found") +} + +func TestPrintInputListHuman_RendersTable(t *testing.T) { + out := captureStdout(t, func() { PrintInputListHuman([]Input{sampleInput()}) }) + + assert.Contains(t, out, "INPUT ID") + assert.Contains(t, out, "in-1") + assert.Contains(t, out, "locked") + assert.Contains(t, out, string(InputStateValid)) +} diff --git a/internal/pipeline/input_payload.go b/internal/pipeline/input_payload.go new file mode 100644 index 000000000..dc92530d5 --- /dev/null +++ b/internal/pipeline/input_payload.go @@ -0,0 +1,71 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// input_payload.go contains the shared helper for resolving an input payload +// from either a positional argument or the --from-file flag, then parsing +// it as JSON. +package pipeline + +import ( + "encoding/json" + "errors" + "fmt" + "os" +) + +// ResolvePayload mirrors the create/update flag pattern from +// `dr pipeline create`: a JSON file path can be supplied either as a +// positional argument or via --from-file=; exactly one of the two +// must be provided. The contents of the file must be a JSON object so it +// fits the `{payload: object}` body the API expects. +func ResolvePayload(args []string, fromFile string) (map[string]any, error) { + path, err := resolvePayloadFilePath(args, fromFile) + if err != nil { + return nil, err + } + + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var payload map[string]any + + err = json.Unmarshal(raw, &payload) + if err != nil { + return nil, fmt.Errorf("parse %s as JSON object: %w", path, err) + } + + return payload, nil +} + +// resolvePayloadFilePath returns the file path supplied either positionally or +// via --from-file. Exactly one of the two must be provided. +func resolvePayloadFilePath(args []string, fromFile string) (string, error) { + positional := "" + if len(args) > 0 { + positional = args[0] + } + + switch { + case positional != "" && fromFile != "": + return "", errors.New("specify the file either as a positional argument or via --from-file, not both") + case positional != "": + return positional, nil + case fromFile != "": + return fromFile, nil + default: + return "", errors.New("a JSON payload file is required (positional argument or --from-file)") + } +} diff --git a/internal/pipeline/input_payload_test.go b/internal/pipeline/input_payload_test.go new file mode 100644 index 000000000..a1f594826 --- /dev/null +++ b/internal/pipeline/input_payload_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTemp(t *testing.T, content string) string { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "payload-*.json") + + require.NoError(t, err) + + _, err = f.WriteString(content) + + require.NoError(t, err) + require.NoError(t, f.Close()) + + return f.Name() +} + +// โ”€โ”€ resolvePayloadFilePath โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestResolvePayloadFilePath_Positional(t *testing.T) { + path, err := resolvePayloadFilePath([]string{"/some/file.json"}, "") + + require.NoError(t, err) + assert.Equal(t, "/some/file.json", path) +} + +func TestResolvePayloadFilePath_FromFile(t *testing.T) { + path, err := resolvePayloadFilePath(nil, "/some/file.json") + + require.NoError(t, err) + assert.Equal(t, "/some/file.json", path) +} + +func TestResolvePayloadFilePath_BothProvided(t *testing.T) { + _, err := resolvePayloadFilePath([]string{"/a.json"}, "/b.json") + + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +func TestResolvePayloadFilePath_NeitherProvided(t *testing.T) { + _, err := resolvePayloadFilePath(nil, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +// โ”€โ”€ ResolvePayload โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestResolvePayload_PositionalArg(t *testing.T) { + path := writeTemp(t, `{"key":"value"}`) + + got, err := ResolvePayload([]string{path}, "") + + require.NoError(t, err) + assert.Equal(t, map[string]any{"key": "value"}, got) +} + +func TestResolvePayload_FromFile(t *testing.T) { + path := writeTemp(t, `{"count":42}`) + + got, err := ResolvePayload(nil, path) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"count": float64(42)}, got) +} + +func TestResolvePayload_BothProvided(t *testing.T) { + path := writeTemp(t, `{}`) + + _, err := ResolvePayload([]string{path}, path) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +func TestResolvePayload_NeitherProvided(t *testing.T) { + _, err := ResolvePayload(nil, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestResolvePayload_FileNotFound(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist.json") + + _, err := ResolvePayload([]string{missing}, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "read") +} + +func TestResolvePayload_InvalidJSON(t *testing.T) { + path := writeTemp(t, `not json`) + + _, err := ResolvePayload([]string{path}, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "parse") + assert.Contains(t, err.Error(), "JSON object") +} + +func TestResolvePayload_NonObjectJSON(t *testing.T) { + path := writeTemp(t, `[1, 2, 3]`) + + _, err := ResolvePayload([]string{path}, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "parse") + assert.Contains(t, err.Error(), "JSON object") +} diff --git a/internal/pipeline/input_test.go b/internal/pipeline/input_test.go new file mode 100644 index 000000000..b96574ea4 --- /dev/null +++ b/internal/pipeline/input_test.go @@ -0,0 +1,209 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// installEndpoint sets viper's endpoint to the given URL for the duration +// of the test, restoring the previous value at cleanup. +func installEndpoint(t *testing.T, url string) { + t.Helper() + + prev := viperx.GetString(config.DataRobotURL) + + viperx.Set(config.DataRobotURL, url) + + t.Cleanup(func() { + viperx.Set(config.DataRobotURL, prev) + }) +} + +func TestCreateInput_Draft(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/inputs", r.URL.Path) + + var body InputCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "v", body.Payload["k"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{"k":"v"}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateInput("p-1", ScopeDraft, nil, map[string]any{"k": "v"}) + require.NoError(t, err) + assert.Equal(t, "in-1", got.InputID) + assert.Equal(t, InputStateValid, got.State) +} + +func TestCreateInput_LockedURLShape(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/inputs", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","versionId":2,"isDraft":false,"state":"VALID","payload":{}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := CreateInput("p-1", ScopeLocked, &v, map[string]any{}) + require.NoError(t, err) + require.NotNil(t, got.VersionID) + assert.Equal(t, 2, *got.VersionID) +} + +func TestListInputs_AddsPaginationQuery(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/inputs", r.URL.Path) + assert.Equal(t, "10", r.URL.Query().Get("offset")) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{}}],"totalCount":1,"count":1}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListInputs("p-1", ScopeDraft, nil, 10, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "in-1", items[0].InputID) +} + +func TestListInputs_OmitsZeroPagination(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.URL.RawQuery) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[],"totalCount":0,"count":0}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListInputs("p-1", ScopeDraft, nil, 0, 0) + require.NoError(t, err) + assert.Empty(t, items) +} + +func TestGetInput_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/inputs/in-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetInput("p-1", ScopeDraft, nil, "in-1") + require.NoError(t, err) + assert.Equal(t, "in-1", got.InputID) +} + +func TestUpdateInput_PatchesDraft(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/inputs/in-1", r.URL.Path) + + var body InputUpdateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "new", body.Payload["k"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{"k":"new"}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := UpdateInput("p-1", "in-1", map[string]any{"k": "new"}) + require.NoError(t, err) + assert.Equal(t, "new", got.Payload["k"]) +} + +func TestDeleteInput_LockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/3/inputs/in-1", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 3 + require.NoError(t, DeleteInput("p-1", ScopeLocked, &v, "in-1")) +} + +func TestDeleteInput_PropagatesAPIError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"locked input"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := DeleteInput("p-1", ScopeDraft, nil, "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") +} diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go new file mode 100644 index 000000000..bfa558bb9 --- /dev/null +++ b/internal/pipeline/run.go @@ -0,0 +1,164 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// run.go wraps the pipeline run endpoints described in +// pipelines-api/.../controllers/pipeline_dispatch.py. The CLI exposes the +// same draft/locked URL split as inputs via the shared Scope helpers. +// +// The wire format and server URL paths still use the legacy term +// "dispatch" (e.g. /dispatches, dispatch_id). JSON tags and endpoint +// segments are preserved to keep the API contract intact while the Go +// surface is renamed to "run" to match the new product vocabulary. + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" + "time" +) + +// Run lifecycle states (mirrors PipelineDispatchStatus on the wire). +const ( + RunStatusPending = "PENDING" + RunStatusRunning = "RUNNING" + RunStatusCompleted = "COMPLETED" + RunStatusFailed = "FAILED" + RunStatusCancelled = "CANCELLED" + RunStatusErrored = "ERRORED" +) + +// Run mirrors PipelineDispatchResponse from the pipelines-api. +type Run struct { + RunID string `json:"id"` + PipelineID string `json:"pipelineId"` + VersionID *int `json:"versionId,omitempty"` + InputID string `json:"inputId"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` + TriggeredBy string `json:"triggeredBy"` + Status string `json:"status"` + ErrorDetail string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// RunStatus mirrors PipelineDispatchStatusResponse โ€” the lightweight +// polling-friendly shape returned by GET .../status. +type RunStatus struct { + RunID string `json:"id"` + Status string `json:"status"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` +} + +// RunCreateRequest mirrors PipelineDispatchCreateRequest. +type RunCreateRequest struct { + InputID string `json:"inputId"` +} + +// CreateRun starts a new run for the given input. Returns the +// freshly-created Run (status PENDING). +func CreateRun(pipelineID string, scope Scope, version *int, inputID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + body := RunCreateRequest{InputID: inputID} + + var result Run + + err = doJSON(http.MethodPost, endpoint, body, "create run", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListRuns returns a paginated slice of runs for the given scope. +func ListRuns(pipelineID string, scope Scope, version *int, offset, limit int) ([]Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var page DataPage[Run] + + err = doJSON(http.MethodGet, endpoint, nil, "runs", &page) + if err != nil { + return nil, err + } + + return page.Data, nil +} + +// GetRun fetches a single run by id within the given scope. +func GetRun(pipelineID string, scope Scope, version *int, runID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return nil, err + } + + var run Run + + err = doJSON(http.MethodGet, endpoint, nil, "run", &run) + if err != nil { + return nil, err + } + + return &run, nil +} + +// GetRunStatus calls the lightweight GET .../status endpoint useful for +// polling without re-downloading the full run record. +func GetRunStatus(pipelineID string, scope Scope, version *int, runID string) (*RunStatus, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID+"/status") + if err != nil { + return nil, err + } + + var status RunStatus + + err = doJSON(http.MethodGet, endpoint, nil, "run status", &status) + if err != nil { + return nil, err + } + + return &status, nil +} + +// CancelRun issues a DELETE on a run, transitioning it to CANCELLED if +// it is still in a non-terminal state. +func CancelRun(pipelineID string, scope Scope, version *int, runID string) error { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return err + } + + return doDelete(endpoint, "cancel run") +} diff --git a/internal/pipeline/run_output.go b/internal/pipeline/run_output.go new file mode 100644 index 000000000..c75e056f9 --- /dev/null +++ b/internal/pipeline/run_output.go @@ -0,0 +1,254 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// run_output.go holds the rendering helpers shared by the +// `dr pipeline run` verbs. +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strconv" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// runJSON is the CLI-facing shape used for `--output-format json`. It mirrors +// Run but renames the wire-level fields to the CLI's `run` +// vocabulary (`run_id`, `covalent_run_id`). Decoding still happens +// through Run, which keeps the API wire tags intact. +type runJSON struct { + RunID string `json:"run_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentRunID string `json:"covalent_run_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toRunJSON(r Run) runJSON { + return runJSON{ + RunID: r.RunID, + PipelineID: r.PipelineID, + VersionID: r.VersionID, + InputID: r.InputID, + CovalentRunID: r.CovalentDispatchID, + TriggeredBy: r.TriggeredBy, + Status: r.Status, + ErrorDetail: r.ErrorDetail, + CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: r.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// runStatusJSON mirrors RunStatus with CLI-vocabulary keys. +type runStatusJSON struct { + RunID string `json:"run_id"` + Status string `json:"status"` + CovalentRunID string `json:"covalent_run_id,omitempty"` +} + +func toRunStatusJSON(s RunStatus) runStatusJSON { + return runStatusJSON{ + RunID: s.RunID, + Status: s.Status, + CovalentRunID: s.CovalentDispatchID, + } +} + +// RenderRun routes a single run to JSON or human output. +func RenderRun(format OutputFormat, r Run) error { + if format == OutputFormatJSON { + return PrintRunJSON(r) + } + + PrintRunHuman(r) + + return nil +} + +// RenderRuns routes a list of runs to JSON or human output. +func RenderRuns(format OutputFormat, items []Run) error { + if format == OutputFormatJSON { + return PrintRunListJSON(items) + } + + PrintRunListHuman(items) + + return nil +} + +// RenderRunStatus routes a run status to JSON or human output. +func RenderRunStatus(format OutputFormat, s RunStatus) error { + if format == OutputFormatJSON { + return PrintStatusJSON(s) + } + + PrintStatusHuman(s) + + return nil +} + +// PrintRunJSON marshals a run as indented JSON using CLI-vocabulary keys. +func PrintRunJSON(r Run) error { + data, err := json.MarshalIndent(toRunJSON(r), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunHuman renders a single run in a human-friendly form. +func PrintRunHuman(r Run) { + scope := "draft" + versionDisplay := emptyValuePlaceholder + + if r.VersionID != nil { + scope = "locked" + versionDisplay = strconv.Itoa(*r.VersionID) + } + + covalent := r.CovalentDispatchID + if covalent == "" { + covalent = emptyValuePlaceholder + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", r.RunID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", r.PipelineID) + fmt.Fprintf(w, "Scope:\t%s\n", scope) + fmt.Fprintf(w, "Version:\t%s\n", versionDisplay) + fmt.Fprintf(w, "Input ID:\t%s\n", r.InputID) + fmt.Fprintf(w, "Status:\t%s\n", r.Status) + fmt.Fprintf(w, "Triggered By:\t%s\n", r.TriggeredBy) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) + + if r.ErrorDetail != "" { + fmt.Fprintf(w, "Error:\t%s\n", r.ErrorDetail) + } + + fmt.Fprintf(w, "Created:\t%s\n", r.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", r.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() +} + +// PrintRunListJSON marshals a list of runs as indented JSON using +// CLI-vocabulary keys. +func PrintRunListJSON(items []Run) error { + view := make([]runJSON, len(items)) + + for i, r := range items { + view[i] = toRunJSON(r) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunListHuman renders a lipgloss table summary of runs. +func PrintRunListHuman(items []Run) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No runs found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"RUN ID", "SCOPE", "VERSION", "STATUS", "TRIGGER", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, r := range items { + scope := "draft" + ver := emptyValuePlaceholder + + if r.VersionID != nil { + scope = "locked" + ver = strconv.Itoa(*r.VersionID) + } + + t.Row(r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(timestampFormat)) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +// PrintStatusJSON marshals a lightweight status response as indented JSON +// using CLI-vocabulary keys. +func PrintStatusJSON(s RunStatus) error { + data, err := json.MarshalIndent(toRunStatusJSON(s), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintStatusHuman renders a lightweight status response. +func PrintStatusHuman(s RunStatus) { + covalent := s.CovalentDispatchID + if covalent == "" { + covalent = emptyValuePlaceholder + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", s.RunID) + fmt.Fprintf(w, "Status:\t%s\n", s.Status) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) + + w.Flush() +} diff --git a/internal/pipeline/run_output_test.go b/internal/pipeline/run_output_test.go new file mode 100644 index 000000000..7eb6a28dc --- /dev/null +++ b/internal/pipeline/run_output_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleRun() Run { + ver := 2 + + return Run{ + RunID: "d-1", + PipelineID: "p-1", + VersionID: &ver, + InputID: "in-1", + CovalentDispatchID: "cov-42", + TriggeredBy: "user@example.com", + Status: RunStatusPending, + CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 29, 10, 5, 0, 0, time.UTC), + } +} + +// โ”€โ”€ toRunJSON remapping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestToRunJSON_RemapsWireFields(t *testing.T) { + j := toRunJSON(sampleRun()) + + assert.Equal(t, "d-1", j.RunID) + assert.Equal(t, "p-1", j.PipelineID) + assert.Equal(t, "in-1", j.InputID) + assert.Equal(t, "cov-42", j.CovalentRunID) + assert.Equal(t, RunStatusPending, j.Status) +} + +func TestToRunJSON_FormatsTimestampsAsRFC3339(t *testing.T) { + j := toRunJSON(sampleRun()) + + assert.Equal(t, "2026-04-29T10:00:00Z", j.CreatedAt) + assert.Equal(t, "2026-04-29T10:05:00Z", j.UpdatedAt) +} + +func TestToRunJSON_JSONKeysUseCliVocabulary(t *testing.T) { + data, err := json.Marshal(toRunJSON(sampleRun())) + require.NoError(t, err) + + var raw map[string]any + + require.NoError(t, json.Unmarshal(data, &raw)) + + assert.Contains(t, raw, "run_id", "wire 'id' must be remapped to 'run_id'") + assert.Contains(t, raw, "covalent_run_id", "wire 'covalentDispatchId' must be remapped to 'covalent_run_id'") + assert.NotContains(t, raw, "id", "raw wire key 'id' must not appear in CLI output") + assert.NotContains(t, raw, "covalentDispatchId", "raw wire key must not appear in CLI output") +} + +// โ”€โ”€ RenderRun โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestRenderRun_JSON(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, RenderRun(OutputFormatJSON, sampleRun())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + assert.Equal(t, "d-1", parsed["run_id"]) + assert.Equal(t, "cov-42", parsed["covalent_run_id"]) + assert.Equal(t, "2026-04-29T10:00:00Z", parsed["created_at"]) +} + +func TestRenderRun_Human(t *testing.T) { + out := captureStdout(t, func() { PrintRunHuman(sampleRun()) }) + + assert.Contains(t, out, "d-1") + assert.Contains(t, out, "locked") + assert.Contains(t, out, "in-1") + assert.Contains(t, out, RunStatusPending) +} + +// โ”€โ”€ RenderRunStatus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestRenderRunStatus_JSON(t *testing.T) { + s := RunStatus{RunID: "d-1", Status: RunStatusRunning, CovalentDispatchID: "cov-42"} + + out := captureStdout(t, func() { + require.NoError(t, RenderRunStatus(OutputFormatJSON, s)) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + assert.Equal(t, "d-1", parsed["run_id"]) + assert.Equal(t, "cov-42", parsed["covalent_run_id"]) + assert.NotContains(t, parsed, "id") + assert.NotContains(t, parsed, "covalentDispatchId") +} + +// โ”€โ”€ PrintRunListJSON โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestPrintRunListJSON_RemapsFields(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, PrintRunListJSON([]Run{sampleRun()})) + }) + + var parsed []map[string]any + + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + require.Len(t, parsed, 1) + assert.Equal(t, "d-1", parsed[0]["run_id"]) + assert.Equal(t, "cov-42", parsed[0]["covalent_run_id"]) +} + +func TestPrintRunListHuman_Empty(t *testing.T) { + out := captureStdout(t, func() { PrintRunListHuman(nil) }) + assert.Contains(t, out, "No runs found") +} + +func TestPrintRunListHuman_RendersTable(t *testing.T) { + out := captureStdout(t, func() { PrintRunListHuman([]Run{sampleRun()}) }) + + assert.Contains(t, out, "RUN ID") + assert.Contains(t, out, "d-1") + assert.Contains(t, out, "locked") + assert.Contains(t, out, RunStatusPending) +} diff --git a/internal/pipeline/run_test.go b/internal/pipeline/run_test.go new file mode 100644 index 000000000..3fd14228d --- /dev/null +++ b/internal/pipeline/run_test.go @@ -0,0 +1,168 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateRun_DraftURLAndBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches", r.URL.Path) + + var body RunCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "in-1", body.InputID) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateRun("p-1", ScopeDraft, nil, "in-1") + require.NoError(t, err) + assert.Equal(t, "d-1", got.RunID) + assert.Equal(t, RunStatusPending, got.Status) +} + +func TestCreateRun_LockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","versionId":2,"inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := CreateRun("p-1", ScopeLocked, &v, "in-1") + require.NoError(t, err) + require.NotNil(t, got.VersionID) + assert.Equal(t, 2, *got.VersionID) +} + +func TestListRuns_QueryAndDecode(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches", r.URL.Path) + assert.Equal(t, "10", r.URL.Query().Get("offset")) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"RUNNING"}],"totalCount":1,"count":1}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListRuns("p-1", ScopeDraft, nil, 10, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, RunStatusRunning, items[0].Status) +} + +func TestGetRun_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"COMPLETED"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetRun("p-1", ScopeDraft, nil, "d-1") + require.NoError(t, err) + assert.Equal(t, RunStatusCompleted, got.Status) +} + +func TestGetRunStatus_StatusEndpointURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches/d-1/status", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","status":"RUNNING","covalentDispatchId":"cov-x"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := GetRunStatus("p-1", ScopeLocked, &v, "d-1") + require.NoError(t, err) + assert.Equal(t, RunStatusRunning, got.Status) + assert.Equal(t, "cov-x", got.CovalentDispatchID) +} + +func TestCancelRun_DeletesDraftURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, CancelRun("p-1", ScopeDraft, nil, "d-1")) +} + +func TestCancelRun_PropagatesConflict(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"already terminal"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := CancelRun("p-1", ScopeDraft, nil, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") + assert.Contains(t, err.Error(), "already terminal") +} diff --git a/internal/pipeline/transport_test.go b/internal/pipeline/transport_test.go index b8a051bab..fb0b578b5 100644 --- a/internal/pipeline/transport_test.go +++ b/internal/pipeline/transport_test.go @@ -46,20 +46,6 @@ func installSkipAuth(t *testing.T) { }) } -// installEndpoint temporarily sets the DataRobot URL viper key to url, -// restoring the previous value at test cleanup. -func installEndpoint(t *testing.T, url string) { - t.Helper() - - prev := viperx.GetString(config.DataRobotURL) - - viperx.Set(config.DataRobotURL, url) - - t.Cleanup(func() { - viperx.Set(config.DataRobotURL, prev) - }) -} - func TestBuildJSONRequest_BodyAndHeaders(t *testing.T) { installSkipAuth(t) diff --git a/internal/state/state.go b/internal/state/state.go index 17c4e1748..62e28ec07 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -20,6 +20,7 @@ import ( "time" "github.com/datarobot/cli/internal/config/viperx" + "github.com/datarobot/cli/internal/log" "github.com/datarobot/cli/internal/version" "gopkg.in/yaml.v3" ) @@ -37,6 +38,10 @@ type state struct { LastDotenvSetup *time.Time `yaml:"last_dotenv_setup,omitempty"` // LastSuccessDepsCheck is an ISO8601-compliant timestamp of the last successful `dr dependencies check` or `dr dependencies install` run LastSuccessDepsCheck *time.Time `yaml:"last_success_deps_check,omitempty"` + // TemplateName is the name of the DataRobot template used to create this project + TemplateName string `yaml:"template_name,omitempty"` + // TemplateID is the stable identifier of the DataRobot template used to create this project + TemplateID string `yaml:"template_id,omitempty"` } // getStatePath determines the appropriate location for the state file. @@ -133,7 +138,7 @@ func UpdateAfterDotenvSetup(repoRoot string) error { } // UpdateAfterTemplatesSetup updates the state file after a successful `dr templates setup` run. -func UpdateAfterTemplatesSetup(repoRoot string) error { +func UpdateAfterTemplatesSetup(repoRoot, templateName, templateID string) error { // Load existing state to preserve other fields existingState, err := load(repoRoot) if err != nil { @@ -142,10 +147,25 @@ func UpdateAfterTemplatesSetup(repoRoot string) error { now := time.Now().UTC() existingState.LastTemplatesSetup = &now + existingState.TemplateName = templateName + existingState.TemplateID = templateID return existingState.update() } +// GetTemplateInfo returns the template name and ID stored in the project state file. +// Returns empty strings if no state file exists or no template info was recorded. +func GetTemplateInfo(repoRoot string) (name, id string) { + existingState, err := load(repoRoot) + if err != nil { + log.Debugf("Failed to load state for template info: %v", err) + + return "", "" + } + + return existingState.TemplateName, existingState.TemplateID +} + // UpdateAfterSuccessDepsCheck updates the state file after a successful `dr dependencies check` or `dr dependencies install` run. func UpdateAfterSuccessDepsCheck(repoRoot string) error { existingState, err := load(repoRoot) diff --git a/internal/state/state_test.go b/internal/state/state_test.go index cd52b696b..6ab0d8141 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -248,3 +248,111 @@ func TestUpdateAfterSuccessDepsCheck_PreservesOtherFields(t *testing.T) { require.NotNil(t, stateAfter.LastStart) assert.Equal(t, stateBefore.LastStart.Unix(), stateAfter.LastStart.Unix()) } + +func TestUpdateAfterTemplatesSetup(t *testing.T) { + t.Run("saves template name, ID and timestamp", func(t *testing.T) { + tmpDir := t.TempDir() + localStateDir := filepath.Join(tmpDir, ".datarobot", "cli") + err := os.MkdirAll(localStateDir, 0o755) + require.NoError(t, err) + + beforeUpdate := time.Now().UTC() + + err = UpdateAfterTemplatesSetup(tmpDir, "my-awesome-template", "tmpl-abc123") + require.NoError(t, err) + + afterUpdate := time.Now().UTC() + + loadedState, err := load(tmpDir) + require.NoError(t, err) + + assert.Equal(t, "my-awesome-template", loadedState.TemplateName) + assert.Equal(t, "tmpl-abc123", loadedState.TemplateID) + assert.NotEmpty(t, loadedState.CLIVersion) + require.NotNil(t, loadedState.LastTemplatesSetup) + assert.True(t, loadedState.LastTemplatesSetup.After(beforeUpdate) || loadedState.LastTemplatesSetup.Equal(beforeUpdate)) + assert.True(t, loadedState.LastTemplatesSetup.Before(afterUpdate) || loadedState.LastTemplatesSetup.Equal(afterUpdate)) + }) + + t.Run("preserves other fields when updating", func(t *testing.T) { + tmpDir := t.TempDir() + localStateDir := filepath.Join(tmpDir, ".datarobot", "cli") + err := os.MkdirAll(localStateDir, 0o755) + require.NoError(t, err) + + err = UpdateAfterSuccessfulRun(tmpDir) + require.NoError(t, err) + + stateBefore, err := load(tmpDir) + require.NoError(t, err) + require.NotNil(t, stateBefore.LastStart) + + err = UpdateAfterTemplatesSetup(tmpDir, "preserved-template", "tmpl-xyz999") + require.NoError(t, err) + + stateAfter, err := load(tmpDir) + require.NoError(t, err) + + assert.Equal(t, "preserved-template", stateAfter.TemplateName) + assert.Equal(t, "tmpl-xyz999", stateAfter.TemplateID) + require.NotNil(t, stateAfter.LastStart) + assert.Equal(t, stateBefore.LastStart.Unix(), stateAfter.LastStart.Unix()) + }) +} + +func TestGetTemplateInfo(t *testing.T) { + t.Run("returns both name and ID when state exists", func(t *testing.T) { + tmpDir := t.TempDir() + localStateDir := filepath.Join(tmpDir, ".datarobot", "cli") + err := os.MkdirAll(localStateDir, 0o755) + require.NoError(t, err) + + err = UpdateAfterTemplatesSetup(tmpDir, "my-template", "tmpl-id-001") + require.NoError(t, err) + + name, id := GetTemplateInfo(tmpDir) + + assert.Equal(t, "my-template", name) + assert.Equal(t, "tmpl-id-001", id) + }) + + t.Run("returns empty strings when no state file exists", func(t *testing.T) { + tmpDir := t.TempDir() + + name, id := GetTemplateInfo(tmpDir) + + assert.Empty(t, name) + assert.Empty(t, id) + }) + + t.Run("returns empty strings when template info not set", func(t *testing.T) { + tmpDir := t.TempDir() + localStateDir := filepath.Join(tmpDir, ".datarobot", "cli") + err := os.MkdirAll(localStateDir, 0o755) + require.NoError(t, err) + + err = UpdateAfterSuccessfulRun(tmpDir) + require.NoError(t, err) + + name, id := GetTemplateInfo(tmpDir) + + assert.Empty(t, name) + assert.Empty(t, id) + }) + + t.Run("returns empty strings when state file is malformed", func(t *testing.T) { + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, ".datarobot", "cli", "state.yaml") + + err := os.MkdirAll(filepath.Dir(statePath), 0o755) + require.NoError(t, err) + + err = os.WriteFile(statePath, []byte("template_name: [invalid"), 0o644) + require.NoError(t, err) + + name, id := GetTemplateInfo(tmpDir) + + assert.Empty(t, name) + assert.Empty(t, id) + }) +} diff --git a/internal/telemetry/properties.go b/internal/telemetry/properties.go index a86822a92..478691766 100644 --- a/internal/telemetry/properties.go +++ b/internal/telemetry/properties.go @@ -22,7 +22,9 @@ import ( "time" "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/repo" "github.com/datarobot/cli/internal/shell" + "github.com/datarobot/cli/internal/state" "github.com/datarobot/cli/internal/version" ) @@ -52,6 +54,8 @@ type CommonProperties struct { CommandKind string // "core" or "plugin", set by the root command after dispatch OrganizationID *string // DataRobot org ID from GET /api/v2/account/info/, cached to disk; nil on network failure or auth issues TenantID *string // DataRobot tenant ID from GET /api/v2/account/info/, cached to disk; nil if unavailable (legit absent for legacy/system accounts) + TemplateName *string // Name of the DataRobot template used to create this project; nil when not inside a template project + TemplateID *string // Stable ID of the DataRobot template used to create this project; nil when not inside a template project } // DetectShell returns the name of the shell the CLI is running from. @@ -102,6 +106,20 @@ func CollectCommonProperties() *CommonProperties { } } + // Read template info from project state if running inside a template project + repoRoot, err := repo.FindRepoRoot() + if err == nil { + name, id := state.GetTemplateInfo(repoRoot) + + if name != "" { + props.TemplateName = &name + } + + if id != "" { + props.TemplateID = &id + } + } + return props } @@ -126,6 +144,14 @@ func (p *CommonProperties) AsMap() map[string]any { m["tenant_id"] = *p.TenantID } + if p.TemplateName != nil { + m["template_name"] = *p.TemplateName + } + + if p.TemplateID != nil { + m["template_id"] = *p.TemplateID + } + return m } diff --git a/internal/telemetry/properties_test.go b/internal/telemetry/properties_test.go index 6a0a2fd8e..3b25ac066 100644 --- a/internal/telemetry/properties_test.go +++ b/internal/telemetry/properties_test.go @@ -26,6 +26,7 @@ import ( "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/config/viperx" + "github.com/datarobot/cli/internal/state" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -80,6 +81,29 @@ func TestCommonPropertiesAsMap(t *testing.T) { // session_id, user_id, and device_id are top-level Amplitude fields, not event properties assert.NotContains(t, m, "session_id") assert.NotContains(t, m, "user_id") + // template_name is absent when not set + assert.NotContains(t, m, "template_name") +} + +func TestCommonPropertiesAsMap_IncludesTemplateName(t *testing.T) { + props := &CommonProperties{ + TemplateName: ptrString("my-template"), + TemplateID: ptrString("tmpl-abc123"), + } + + m := props.AsMap() + + assert.Equal(t, "my-template", m["template_name"]) + assert.Equal(t, "tmpl-abc123", m["template_id"]) +} + +func TestCommonPropertiesAsMap_OmitsTemplateNameWhenNil(t *testing.T) { + props := &CommonProperties{} + + m := props.AsMap() + + assert.NotContains(t, m, "template_name") + assert.NotContains(t, m, "template_id") } func TestGetOrCreateDeviceID_CreatesAndPersists(t *testing.T) { @@ -324,3 +348,70 @@ func TestDetectShell_ReturnsNonEmpty(t *testing.T) { assert.NotEmpty(t, shell) assert.NotEqual(t, "unknown", shell) } + +func TestCollectCommonProperties_TemplateNameFromState(t *testing.T) { + tmpDir := t.TempDir() + + // Resolve symlinks (important for macOS where /var -> /private/var) + tmpDir, err := filepath.EvalSymlinks(tmpDir) + require.NoError(t, err) + + // Create .datarobot/answers to satisfy repo detection + answersDir := filepath.Join(tmpDir, ".datarobot", "answers") + err = os.MkdirAll(answersDir, 0o755) + require.NoError(t, err) + + // Write state with template name and ID + err = state.UpdateAfterTemplatesSetup(tmpDir, "telemetry-test-template", "tmpl-tst-001") + require.NoError(t, err) + + // Change to temp dir so FindRepoRoot() finds it + originalWd, err := os.Getwd() + require.NoError(t, err) + + defer func() { + require.NoError(t, os.Chdir(originalWd)) + }() + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + props := CollectCommonProperties() + + require.NotNil(t, props.TemplateName) + assert.Equal(t, "telemetry-test-template", *props.TemplateName) + require.NotNil(t, props.TemplateID) + assert.Equal(t, "tmpl-tst-001", *props.TemplateID) + + m := props.AsMap() + + assert.Equal(t, "telemetry-test-template", m["template_name"]) + assert.Equal(t, "tmpl-tst-001", m["template_id"]) +} + +func TestCollectCommonProperties_TemplateNameNilOutsideProject(t *testing.T) { + tmpDir := t.TempDir() + + // No .datarobot directory โ€” not a template project + originalWd, err := os.Getwd() + require.NoError(t, err) + + defer func() { + require.NoError(t, os.Chdir(originalWd)) + }() + + err = os.Chdir(tmpDir) + require.NoError(t, err) + + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + props := CollectCommonProperties() + + assert.Nil(t, props.TemplateName) + + m := props.AsMap() + + assert.NotContains(t, m, "template_name") +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index d15db1798..22944be7a 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -53,8 +53,10 @@ type Client struct { const amplitudeLogPrefix = "[amplitude] " // amplitudeLogger adapts the internal log package to Amplitude's Logger interface. -// Amplitude's INFO logs (HTTP responses, variable additions) are demoted to DEBUG -// when the app's log level is above INFO, keeping stderr clean by default. +// Amplitude's INFO/WARN/ERROR logs (HTTP responses, retry exhaustion, etc.) are +// demoted to DEBUG when the app's log level is above INFO, keeping stderr clean +// by default โ€” telemetry must never surface user-visible errors. They reappear +// at INFO/WARN under --verbose/--debug for diagnostics. // All messages are prefixed with [amplitude] for traceability in debug log files. type amplitudeLogger struct{} @@ -71,11 +73,19 @@ func (l *amplitudeLogger) Infof(msg string, args ...any) { } func (l *amplitudeLogger) Warnf(msg string, args ...any) { - log.Warnf(amplitudeLogPrefix+msg, args...) + if log.IsVerbose() { + log.Warnf(amplitudeLogPrefix+msg, args...) + } else { + log.Debugf(amplitudeLogPrefix+msg, args...) + } } func (l *amplitudeLogger) Errorf(msg string, args ...any) { - log.Errorf(amplitudeLogPrefix+msg, args...) + if log.IsVerbose() { + log.Warnf(amplitudeLogPrefix+msg, args...) + } else { + log.Debugf(amplitudeLogPrefix+msg, args...) + } } // NewClient creates a telemetry client. If IsEnabled() returns true, it initializes diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index c3af7c0da..62d1a60ee 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -235,3 +235,50 @@ func TestAmplitudeLogger_DoesNotPanic(t *testing.T) { logger.Warnf("test %s", "message") logger.Errorf("test %s", "message") } + +func TestTrack_TemplateFieldsInEventProperties(t *testing.T) { + originalAPIKey := AmplitudeAPIKey + + defer func() { AmplitudeAPIKey = originalAPIKey }() + + AmplitudeAPIKey = "" + + props := &CommonProperties{ + TemplateName: ptrString("my-template"), + TemplateID: ptrString("tmpl-abc123"), + } + + client := NewClient(props) + + var captured types.Event + + // Intercept the event by inspecting EventProperties after Track merges them. + // Since amp is nil (dev mode), Track only logs โ€” we validate via AsMap directly. + eventProps := client.props.AsMap() + + // Simulate Track merge: common props are the base, event-specific props override. + captured.EventProperties = eventProps + + assert.Equal(t, "my-template", captured.EventProperties["template_name"]) + assert.Equal(t, "tmpl-abc123", captured.EventProperties["template_id"]) +} + +func TestTrack_TemplateFieldsAbsentWhenNotInProject(t *testing.T) { + originalAPIKey := AmplitudeAPIKey + + defer func() { AmplitudeAPIKey = originalAPIKey }() + + AmplitudeAPIKey = "" + + props := &CommonProperties{ + CLIVersion: "v1.0.0", + // TemplateName and TemplateID intentionally nil + } + + client := NewClient(props) + + eventProps := client.props.AsMap() + + assert.NotContains(t, eventProps, "template_name") + assert.NotContains(t, eventProps, "template_id") +} diff --git a/smoke_test_scripts/run_deps_install_smoke_test.sh b/smoke_test_scripts/run_deps_install_smoke_test.sh new file mode 100755 index 000000000..75f9be57b --- /dev/null +++ b/smoke_test_scripts/run_deps_install_smoke_test.sh @@ -0,0 +1,509 @@ +#!/bin/bash +# Smoke tests for `dr dependency install` +# +# Scenarios: +# 1. `dr dependency install --yes` skips prompt, exits 0 +# 2. `dr dependency install -y` short flag alias, exits 0 +# 3. `DATAROBOT_CLI_NON_INTERACTIVE=true dr dependency install` +# env var bypasses prompt, exits 0 +# 4. All tools present โ†’ "already up to date" path (no install triggered) +# 5. `dr dependency check` exits 0 after install +# 6. User types "y" at interactive prompt โ†’ install proceeds, exits 0 +# 7. User types "n" at interactive prompt โ†’ install declined, exits 0, nothing installed +# +# Before each test the state of python3/uv/pulumi/task is snapshotted. +# After each test any tool that was absent beforehand is uninstalled so the +# next test starts from the same baseline. +# +# Usage: +# DR_BIN=./dist/dr bash smoke_test_scripts/run_deps_install_smoke_test.sh +# bash smoke_test_scripts/run_deps_install_smoke_test.sh # uses dr on PATH + +set -e + +export TERM="dumb" + +DR_BIN="${DR_BIN:-dr}" + +TRACKED_TOOLS="python3 uv pulumi task" + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Assertion helpers +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +PASS_COUNT=0 +FAIL_COUNT=0 + +assert_exit_zero() { + local cmd_desc="$1" + local exit_code="$2" + + if [ "$exit_code" -eq 0 ]; then + echo " โœ… assert_exit_zero: $cmd_desc" + else + echo " โŒ assert_exit_zero FAILED: $cmd_desc exited $exit_code" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +assert_output_contains() { + local output="$1" + local pattern="$2" + local label="${3:-contains '$pattern'}" + + if echo "$output" | grep -q "$pattern"; then + echo " โœ… assert_output_contains: $label" + else + echo " โŒ assert_output_contains FAILED: $label" + echo " Expected to find: $pattern" + echo " In output: $output" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +assert_output_not_contains() { + local output="$1" + local pattern="$2" + local label="${3:-does not contain '$pattern'}" + + if ! echo "$output" | grep -q "$pattern"; then + echo " โœ… assert_output_not_contains: $label" + else + echo " โŒ assert_output_not_contains FAILED: $label" + echo " Did not expect to find: $pattern" + echo " In output: $output" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +pass_test() { + echo "โœ… $1 PASSED" + PASS_COUNT=$((PASS_COUNT + 1)) +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# State snapshot & cleanup +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +# Current snapshot file path โ€” set by snapshot_tool_state, consumed by restore_tool_state. +_SNAPSHOT_FILE="" + +# snapshot_tool_state โ€” call at the top of each test. +# Writes one line per tracked tool: :: +# status = "installed" | "not_installed" +# path = absolute path from `command -v`, or "-" when not installed +# Also prints the state so CI logs show the baseline at test entry. +snapshot_tool_state() { + _SNAPSHOT_FILE=$(mktemp /tmp/dr-deps-snapshot.XXXXXX) + + echo " --- tool state before test ---" + + local tool + + for tool in $TRACKED_TOOLS; do + local status path ver + + if command -v "$tool" >/dev/null 2>&1; then + path=$(command -v "$tool") + status="installed" + case "$tool" in + python3) ver=$(python3 --version 2>&1 | head -1) ;; + uv) ver=$(uv --version 2>&1 | head -1) ;; + pulumi) ver=$(pulumi version 2>&1 | head -1) ;; + task) ver=$(task --version 2>&1 | head -1) ;; + *) ver="unknown" ;; + esac + echo " $tool: $ver ($path)" + else + path="-" + status="not_installed" + echo " $tool: not installed" + fi + + echo "${tool}:${status}:${path}" >> "$_SNAPSHOT_FILE" + done + + echo " ------------------------------" +} + +# uninstall_tool โ€” best-effort removal of a single tool. +# Uses the reverse of the platform install commands in tools/prerequisites.go. +# Errors are suppressed so a failed uninstall never aborts the test suite. +uninstall_tool() { + local tool="$1" + + echo " ๐Ÿงน cleanup: removing $tool..." + + case "$(uname -s)" in + Darwin) + case "$tool" in + python3) brew uninstall python 2>/dev/null || true ;; + uv) brew uninstall uv 2>/dev/null || true ;; + pulumi) brew uninstall pulumi 2>/dev/null || true ;; + task) brew uninstall go-task/tap/go-task 2>/dev/null || true ;; + esac + ;; + Linux) + case "$tool" in + uv) + # astral.sh installer puts binaries in ~/.local/bin + rm -f "$HOME/.local/bin/uv" "$HOME/.local/bin/uvx" 2>/dev/null || true + ;; + pulumi) + # get.pulumi.com installer creates ~/.pulumi + rm -rf "$HOME/.pulumi" 2>/dev/null || true + local bin_path + bin_path=$(command -v pulumi 2>/dev/null || true) + [ -n "$bin_path" ] && rm -f "$bin_path" 2>/dev/null || true + ;; + task) + # taskfile.dev installer puts task on PATH; remove it + local bin_path + bin_path=$(command -v task 2>/dev/null || true) + [ -n "$bin_path" ] && rm -f "$bin_path" 2>/dev/null || true + ;; + python3) + # Do not remove python3 on Linux โ€” system may depend on it + echo " โš ๏ธ cleanup: skipping python3 removal on Linux (system dependency)" + ;; + esac + ;; + esac + + echo " ๐Ÿงน cleanup: $tool removed" +} + +# restore_tool_state โ€” call at the end of each test (including on failure). +# Compares current tool presence against the snapshot taken at test start. +# Any tool that was absent before the test but present now is uninstalled. +restore_tool_state() { + if [ -z "$_SNAPSHOT_FILE" ] || [ ! -f "$_SNAPSHOT_FILE" ]; then + return 0 + fi + + local changed=0 + local tool + + for tool in $TRACKED_TOOLS; do + local was_installed + was_installed=$(grep "^${tool}:" "$_SNAPSHOT_FILE" | cut -d: -f2) + + if [ "$was_installed" = "not_installed" ] && command -v "$tool" >/dev/null 2>&1; then + changed=1 + uninstall_tool "$tool" + fi + done + + if [ "$changed" -eq 0 ]; then + echo " ๐Ÿงน cleanup: no new tools to remove" + fi + + rm -f "$_SNAPSHOT_FILE" + _SNAPSHOT_FILE="" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Pre-flight check +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +preflight_check() { + if ! command -v "$DR_BIN" >/dev/null 2>&1 && [ ! -x "$DR_BIN" ]; then + echo "ERROR: dr binary not found at '$DR_BIN'" + echo " Build first with: task build" + echo " Or set: DR_BIN=./dist/dr" + exit 1 + fi + + echo "dr binary: $("$DR_BIN" --version 2>&1 | head -1)" + echo "" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 1: --yes flag skips interactive prompt +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_yes_flag() { + local TEST_NAME="TEST 1: dr dependency install --yes skips prompt" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + snapshot_tool_state + + local output + local exit_code=0 + output=$("$DR_BIN" dependency install --yes 2>&1) || exit_code=$? + + echo "--- output ---" + echo "$output" + echo "--- end output ---" + + assert_exit_zero "dr dependency install --yes" "$exit_code" + + assert_output_not_contains "$output" "Install now? (y/n)" \ + "interactive prompt is not shown when --yes is set" + + assert_output_contains "$output" "up to date\|installed\|Installing" \ + "output indicates install outcome" + + restore_tool_state + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 2: -y short flag alias +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_short_yes_flag() { + local TEST_NAME="TEST 2: dr dependency install -y short flag alias" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + snapshot_tool_state + + local output + local exit_code=0 + output=$("$DR_BIN" dependency install -y 2>&1) || exit_code=$? + + echo "--- output ---" + echo "$output" + echo "--- end output ---" + + assert_exit_zero "dr dependency install -y" "$exit_code" + + assert_output_not_contains "$output" "Install now? (y/n)" \ + "interactive prompt is not shown when -y is set" + + assert_output_contains "$output" "up to date\|installed\|Installing" \ + "output indicates install outcome" + + restore_tool_state + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 3: DATAROBOT_CLI_NON_INTERACTIVE bypasses prompt +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_non_interactive_env() { + local TEST_NAME="TEST 3: DATAROBOT_CLI_NON_INTERACTIVE=true bypasses prompt" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + snapshot_tool_state + + local output + local exit_code=0 + output=$(DATAROBOT_CLI_NON_INTERACTIVE=true "$DR_BIN" dependency install 2>&1) || exit_code=$? + + echo "--- output ---" + echo "$output" + echo "--- end output ---" + + assert_exit_zero "DATAROBOT_CLI_NON_INTERACTIVE=true dr dependency install" "$exit_code" + + assert_output_not_contains "$output" "Install now? (y/n)" \ + "interactive prompt is not shown when DATAROBOT_CLI_NON_INTERACTIVE is set" + + assert_output_contains "$output" "up to date\|installed\|Installing" \ + "output indicates install outcome" + + restore_tool_state + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 4: All tools present โ†’ "already up to date" (no install) +# +# Skips if any tool is absent โ€” "up to date" only makes sense when all present. +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_all_satisfied_no_install() { + local TEST_NAME="TEST 4: all tools present โ†’ already up to date, no install" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + snapshot_tool_state + + # Ensure all tools are installed before asserting the "up to date" path. + # Snapshot was taken above, so restore_tool_state will clean up anything + # installed here together with anything installed by the assertion run. + "$DR_BIN" dependency install --yes >/dev/null 2>&1 || true + + local output + local exit_code=0 + output=$("$DR_BIN" dependency install --yes 2>&1) || exit_code=$? + + echo "--- output ---" + echo "$output" + echo "--- end output ---" + + assert_exit_zero "dr dependency install --yes (all present)" "$exit_code" + + assert_output_contains "$output" "up to date" \ + "output says 'up to date' when all tools are satisfied" + + assert_output_not_contains "$output" "Installing" \ + "no installation is triggered when all tools are satisfied" + + restore_tool_state + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 5: dr dependency check agrees with install outcome +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_check_after_install() { + local TEST_NAME="TEST 5: dr dependency check succeeds after install" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + snapshot_tool_state + + # Ensure tools are installed before running check + "$DR_BIN" dependency install --yes >/dev/null 2>&1 || true + + local output + local exit_code=0 + output=$("$DR_BIN" dependency check 2>&1) || exit_code=$? + + echo "--- output ---" + echo "$output" + echo "--- end output ---" + + assert_exit_zero "dr dependency check" "$exit_code" + + assert_output_contains "$output" "up to date" \ + "dependency check reports all satisfied after install" + + restore_tool_state + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 6: User types "y" at the interactive prompt โ†’ install proceeds +# +# Pipes "y\n" to stdin so helpers.Confirm reads it and returns true. +# When all tools are already present the prompt is never shown (early exit), +# so this test is valid in both cases: tools present โ†’ "up to date", exit 0; +# tools missing โ†’ prompt shown, "y" consumed, install runs, exit 0. +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_interactive_user_y() { + local TEST_NAME="TEST 6: interactive install confirmed with 'y' from user" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + snapshot_tool_state + + local output + local exit_code=0 + output=$(printf "y\n" | "$DR_BIN" dependency install 2>&1) || exit_code=$? + + echo "--- output ---" + echo "$output" + echo "--- end output ---" + + assert_exit_zero "dr dependency install (user answered y)" "$exit_code" + + assert_output_contains "$output" "up to date\|installed\|Installing" \ + "output indicates install outcome (install ran or already satisfied)" + + # When tools were missing the prompt must have appeared and been answered. + # Skip this sub-assertion when the "up to date" early-exit path was taken. + if ! echo "$output" | grep -q "up to date"; then + assert_output_contains "$output" "Install now? (y/n)" \ + "interactive prompt was displayed before install" + fi + + restore_tool_state + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 7: User types "n" at the interactive prompt โ†’ install cancelled +# +# Pipes "n\n" to stdin so helpers.Confirm reads it and returns false. +# Command must exit 0 (decline is not an error) and must not install anything. +# When all tools are already present the prompt is skipped entirely ("up to date"). +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_interactive_user_n() { + local TEST_NAME="TEST 7: interactive install declined with 'n' from user" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + snapshot_tool_state + + local output + local exit_code=0 + output=$(printf "n\n" | "$DR_BIN" dependency install 2>&1) || exit_code=$? + + echo "--- output ---" + echo "$output" + echo "--- end output ---" + + assert_exit_zero "dr dependency install (user answered n)" "$exit_code" + + # Decline must never trigger an installation + assert_output_not_contains "$output" "๐Ÿ“ฆ Installing" \ + "no installation runs when user declines" + + # When tools were missing, verify the prompt was shown before the decline + if ! echo "$output" | grep -q "up to date"; then + assert_output_contains "$output" "Install now? (y/n)" \ + "interactive prompt was displayed before decline" + fi + + restore_tool_state + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Main +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +main() { + echo "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—" + echo "โ•‘ dr dependency install โ€” Smoke Tests โ•‘" + echo "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "" + + preflight_check + + test_yes_flag + test_short_yes_flag + test_non_interactive_env + test_all_satisfied_no_install + test_check_after_install + test_interactive_user_y + test_interactive_user_n + + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "Results: $PASS_COUNT passed, $FAIL_COUNT failed" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + if [ "$FAIL_COUNT" -gt 0 ]; then + echo "โŒ Some tests failed." + exit 1 + else + echo "โœ… All dependency install smoke tests passed." + fi +} + +main "$@" diff --git a/smoke_test_scripts/run_install_integration_test.sh b/smoke_test_scripts/run_install_integration_test.sh new file mode 100755 index 000000000..dc2d97019 --- /dev/null +++ b/smoke_test_scripts/run_install_integration_test.sh @@ -0,0 +1,410 @@ +#!/bin/bash +# Integration tests for install.sh using LOCAL_BINARY +# +# These tests exercise the install.sh code paths without downloading from GitHub. +# They run on PRs using a locally-built binary via LOCAL_BINARY=./dist/dr. +# +# Scenarios: +# 1. Fresh install to default INSTALL_DIR +# 2. Fresh install to custom INSTALL_DIR +# 3. Reinstall same version โ†’ "already up to date" path +# 4. Upgrade: old version already installed โ†’ install newer via LOCAL_BINARY +# 5. 'datarobot' alias created and executable after install +# 6. Binary is on PATH after install (shell profile patching) +# +# Usage: +# LOCAL_BINARY=./dist/dr bash smoke_test_scripts/run_install_integration_test.sh +# +# Requirements: +# - LOCAL_BINARY must point to the binary to install (built via `task build`) +# - Must run on Linux or macOS (not Windows โ€” use install.ps1 tests for that) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +INSTALL_SCRIPT="$REPO_ROOT/install.sh" + +LOCAL_BINARY="${LOCAL_BINARY:-$REPO_ROOT/dist/dr}" + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Assertion helpers +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +PASS_COUNT=0 +FAIL_COUNT=0 + +assert_file_exists() { + local path="$1" + local label="${2:-$path}" + + if [ -e "$path" ]; then + echo " โœ… assert_file_exists: $label" + else + echo " โŒ assert_file_exists FAILED: $label does not exist" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +assert_executable() { + local path="$1" + + if [ -x "$path" ]; then + echo " โœ… assert_executable: $path" + else + echo " โŒ assert_executable FAILED: $path is not executable" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +assert_symlink() { + local path="$1" + + if [ -L "$path" ]; then + echo " โœ… assert_symlink: $path" + else + echo " โŒ assert_symlink FAILED: $path is not a symlink" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +assert_output_contains() { + local output="$1" + local expected="$2" + local label="${3:-contains '$expected'}" + + if echo "$output" | grep -q "$expected"; then + echo " โœ… assert_output_contains: $label" + else + echo " โŒ assert_output_contains FAILED: $label" + echo " Expected to find: $expected" + echo " In output: $output" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +assert_output_not_contains() { + local output="$1" + local unexpected="$2" + local label="${3:-does not contain '$unexpected'}" + + if ! echo "$output" | grep -q "$unexpected"; then + echo " โœ… assert_output_not_contains: $label" + else + echo " โŒ assert_output_not_contains FAILED: $label" + echo " Did not expect to find: $unexpected" + echo " In output: $output" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +pass_test() { + local name="$1" + + echo "โœ… $name PASSED" + PASS_COUNT=$((PASS_COUNT + 1)) +} + +fail_test() { + local name="$1" + local reason="${2:-see output above}" + + echo "โŒ $name FAILED: $reason" + FAIL_COUNT=$((FAIL_COUNT + 1)) +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Setup helpers +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +make_install_dir() { + mktemp -d /tmp/dr-install-test.XXXXXX +} + +run_install() { + local install_dir="$1" + local extra_env="${2:-}" + + env INSTALL_DIR="$install_dir" LOCAL_BINARY="$LOCAL_BINARY" $extra_env sh "$INSTALL_SCRIPT" 2>&1 +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Pre-flight check +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +preflight_check() { + if [ ! -f "$LOCAL_BINARY" ]; then + echo "ERROR: LOCAL_BINARY not found at $LOCAL_BINARY" + echo " Build first with: task build" + echo " Or set: LOCAL_BINARY=/path/to/dr" + exit 1 + fi + + if [ ! -x "$LOCAL_BINARY" ]; then + echo "ERROR: LOCAL_BINARY is not executable: $LOCAL_BINARY" + exit 1 + fi + + if [ ! -f "$INSTALL_SCRIPT" ]; then + echo "ERROR: install.sh not found at $INSTALL_SCRIPT" + exit 1 + fi +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 1: Fresh install to default INSTALL_DIR +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_fresh_install_default_dir() { + local TEST_NAME="TEST 1: fresh install to default INSTALL_DIR" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + local install_dir + install_dir=$(make_install_dir) + + local output + output=$(run_install "$install_dir") + + echo "--- install output ---" + echo "$output" + echo "--- end output ---" + + assert_file_exists "$install_dir/dr" "dr binary" + assert_executable "$install_dir/dr" + assert_symlink "$install_dir/datarobot" "datarobot alias" + assert_output_contains "$output" "Installing local binary" "install used LOCAL_BINARY path" + + local version_out + version_out=$("$install_dir/dr" --version 2>&1 || true) + assert_output_contains "$version_out" "DataRobot\|version\|v[0-9]" "binary executes and prints version" + + rm -rf "$install_dir" + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 2: Fresh install to custom INSTALL_DIR +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_fresh_install_custom_dir() { + local TEST_NAME="TEST 2: fresh install to custom INSTALL_DIR" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + local custom_dir + custom_dir=$(mktemp -d /tmp/dr-custom-bin.XXXXXX) + + local output + output=$(run_install "$custom_dir") + + echo "--- install output ---" + echo "$output" + echo "--- end output ---" + + assert_file_exists "$custom_dir/dr" "dr binary in custom dir" + assert_executable "$custom_dir/dr" + assert_symlink "$custom_dir/datarobot" "datarobot alias in custom dir" + + rm -rf "$custom_dir" + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 3: Reinstall is idempotent โ€” binary still works after second run +# +# When LOCAL_BINARY is set, install.sh skips check_existing_installation +# and always copies. Verify the script exits 0 and the binary is intact. +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_reinstall_idempotent() { + local TEST_NAME="TEST 3: reinstall is idempotent (LOCAL_BINARY re-copy)" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + local install_dir + install_dir=$(make_install_dir) + + # First install + run_install "$install_dir" > /dev/null 2>&1 + + # Second install (same binary) + local exit_code=0 + run_install "$install_dir" > /dev/null 2>&1 || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + echo " โœ… second install exited 0" + else + echo " โŒ second install exited $exit_code" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + + assert_file_exists "$install_dir/dr" "dr binary still present after reinstall" + assert_executable "$install_dir/dr" + assert_symlink "$install_dir/datarobot" "datarobot alias still present after reinstall" + + local version_out + version_out=$("$install_dir/dr" --version 2>&1 || true) + assert_output_contains "$version_out" "DataRobot\|version\|v[0-9]" "binary still executes after reinstall" + + rm -rf "$install_dir" + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 4: 'datarobot' alias is functional (runs --help) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_datarobot_alias_functional() { + local TEST_NAME="TEST 4: datarobot alias is functional" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + local install_dir + install_dir=$(make_install_dir) + + run_install "$install_dir" > /dev/null 2>&1 + + local help_output + help_output=$("$install_dir/datarobot" --help 2>&1 || true) + + echo "--- datarobot --help output ---" + echo "$help_output" + echo "--- end output ---" + + assert_output_contains "$help_output" "DataRobot\|datarobot\|dr\|Build AI" \ + "datarobot alias runs DR CLI" + + rm -rf "$install_dir" + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 5: dr --help runs without error +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_dr_help_runs() { + local TEST_NAME="TEST 5: dr --help runs without error" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + local install_dir + install_dir=$(make_install_dir) + + run_install "$install_dir" > /dev/null 2>&1 + + local exit_code=0 + "$install_dir/dr" --help > /dev/null 2>&1 || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + echo " โœ… dr --help exited 0" + else + echo " โŒ dr --help exited $exit_code" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + + rm -rf "$install_dir" + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 6: Install dir created if missing +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_install_dir_created() { + local TEST_NAME="TEST 6: INSTALL_DIR created when it does not exist" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + local base_dir + base_dir=$(make_install_dir) + local new_dir="$base_dir/nested/bin" + + local output + output=$(run_install "$new_dir") + + echo "--- install output ---" + echo "$output" + echo "--- end output ---" + + assert_file_exists "$new_dir/dr" "dr binary in newly created dir" + + rm -rf "$base_dir" + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Test 7: Binary permissions are correct (executable bit) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +test_binary_permissions() { + local TEST_NAME="TEST 7: installed binary has executable permission" + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "$TEST_NAME" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + local install_dir + install_dir=$(make_install_dir) + + run_install "$install_dir" > /dev/null 2>&1 + + local perms + perms=$(ls -l "$install_dir/dr" | awk '{print $1}') + echo " Permissions: $perms" + + assert_executable "$install_dir/dr" + + rm -rf "$install_dir" + pass_test "$TEST_NAME" +} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Main +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +main() { + echo "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—" + echo "โ•‘ install.sh โ€” LOCAL_BINARY Integration Tests โ•‘" + echo "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "" + echo "LOCAL_BINARY: $LOCAL_BINARY" + echo "INSTALL_SCRIPT: $INSTALL_SCRIPT" + echo "" + + preflight_check + + test_fresh_install_default_dir + test_fresh_install_custom_dir + test_reinstall_idempotent + test_datarobot_alias_functional + test_dr_help_runs + test_install_dir_created + test_binary_permissions + + echo "" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + echo "Results: $PASS_COUNT passed, $FAIL_COUNT failed" + echo "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + + if [ "$FAIL_COUNT" -gt 0 ]; then + echo "โŒ Some tests failed." + exit 1 + else + echo "โœ… All install integration tests passed." + fi +} + +main "$@" diff --git a/smoke_test_scripts/run_self_update_smoke_test.sh b/smoke_test_scripts/run_self_update_smoke_test.sh index 47d8f8e0c..b696016ad 100755 --- a/smoke_test_scripts/run_self_update_smoke_test.sh +++ b/smoke_test_scripts/run_self_update_smoke_test.sh @@ -42,7 +42,12 @@ get_installed_version() { } get_latest_version() { - curl -fsSL "https://api.github.com/repos/datarobot-oss/cli/releases/latest" \ + local auth_header="" + if [ -n "${GITHUB_TOKEN:-}" ]; then + auth_header="Authorization: Bearer $GITHUB_TOKEN" + fi + curl -fsSL ${auth_header:+-H "$auth_header"} \ + "https://api.github.com/repos/datarobot-oss/cli/releases/latest" \ | grep '"tag_name"' \ | sed -E 's/.*"([^"]+)".*/\1/' }