diff --git a/cmd/artifact/cmd_test.go b/cmd/artifact/cmd_test.go new file mode 100644 index 000000000..9f669775b --- /dev/null +++ b/cmd/artifact/cmd_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package artifact + +import ( + "testing" + + "github.com/datarobot/cli/internal/cli" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// findChild returns the named direct subcommand, or nil. +func findChild(cmd *cobra.Command, name string) *cobra.Command { + for _, sub := range cmd.Commands() { + if sub.Name() == name { + return sub + } + } + + return nil +} + +// TestCmd_WorkloadGateHidesDoctor pins the gate-off behavior: without +// DATAROBOT_CLI_FEATURE_WORKLOAD the whole artifact tree (doctor included) is +// filtered out at registration time; with it, doctor is discoverable under +// `artifact code`. +func TestCmd_WorkloadGateHidesDoctor(t *testing.T) { + t.Setenv("DATAROBOT_CLI_FEATURE_WORKLOAD", "") + + gatedRoot := &cli.CommandAdder{Command: &cobra.Command{Use: "root"}} + gatedRoot.AddCommand(Cmd()) + + assert.Nil(t, findChild(gatedRoot.Command, "artifact"), + "gate off: the artifact tree (and doctor with it) is not registered") + + t.Setenv("DATAROBOT_CLI_FEATURE_WORKLOAD", "true") + + openRoot := &cli.CommandAdder{Command: &cobra.Command{Use: "root"}} + openRoot.AddCommand(Cmd()) + + artifactCmd := findChild(openRoot.Command, "artifact") + + require.NotNil(t, artifactCmd, "gate on: artifact registered") + + codeCmd := findChild(artifactCmd, "code") + + require.NotNil(t, codeCmd, "gate on: artifact code registered") + + doctorCmd := findChild(codeCmd, "doctor") + + require.NotNil(t, doctorCmd, "gate on: doctor inherits the artifact tree's gate") + + assert.Empty(t, doctorCmd.Annotations["feature-gate"], + "doctor needs no gate of its own; the parent carries it") +} diff --git a/cmd/artifact/code/cmd.go b/cmd/artifact/code/cmd.go index 286cc89ae..79829fdf2 100644 --- a/cmd/artifact/code/cmd.go +++ b/cmd/artifact/code/cmd.go @@ -17,6 +17,7 @@ package code import ( "github.com/datarobot/cli/cmd/artifact/code/checkout" "github.com/datarobot/cli/cmd/artifact/code/codesync" + doctorcmd "github.com/datarobot/cli/cmd/artifact/code/doctor" initcmd "github.com/datarobot/cli/cmd/artifact/code/init" "github.com/datarobot/cli/cmd/artifact/code/versions" "github.com/spf13/cobra" @@ -42,6 +43,8 @@ Subcommands: versions List catalog versions for the linked artifact. checkout Download a prior version into '.datarobot/workload/.checkouts/' for read-only inspection. + doctor Diagnose the sync state of a project directory (read-only) and + suggest remedies for anything broken. Artifacts must already exist before running 'init'. Create them via 'dr artifact create' or in the DataRobot UI — these commands @@ -58,6 +61,7 @@ Example: cmd.AddCommand(codesync.Cmd()) cmd.AddCommand(versions.Cmd()) cmd.AddCommand(checkout.Cmd()) + cmd.AddCommand(doctorcmd.Cmd()) return cmd } diff --git a/cmd/artifact/code/cmd_test.go b/cmd/artifact/code/cmd_test.go new file mode 100644 index 000000000..38dbf8601 --- /dev/null +++ b/cmd/artifact/code/cmd_test.go @@ -0,0 +1,33 @@ +// 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 code + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersDoctor(t *testing.T) { + c := Cmd() + + names := make([]string, 0, len(c.Commands())) + + for _, sub := range c.Commands() { + names = append(names, sub.Name()) + } + + assert.Contains(t, names, "doctor", "doctor is registered alongside init/sync/versions/checkout") +} diff --git a/cmd/artifact/code/doctor/cmd.go b/cmd/artifact/code/doctor/cmd.go new file mode 100644 index 000000000..1912afff7 --- /dev/null +++ b/cmd/artifact/code/doctor/cmd.go @@ -0,0 +1,403 @@ +// 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 doctor wires the sync-state diagnostics into the +// `dr artifact code doctor` Cobra command. It resolves flags, probes remote +// credentials non-fatally, runs the check suite from internal/workload/doctor +// through the generic framework Runner, renders the report as text or JSON, +// and maps any FAIL to exit code 1. +package doctor + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/cli" + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/log" + "github.com/datarobot/cli/internal/misc/reader" + "github.com/datarobot/cli/internal/outputformat" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/internal/workload" + wldoctor "github.com/datarobot/cli/internal/workload/doctor" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/spf13/cobra" +) + +func init() { + // --yes is read directly from cobra; only the env var binds to viper so an + // explicit --yes never leaks into drconfig.yaml. + _ = viperx.BindEnv(cli.YesFlagName, "DATAROBOT_CLI_NON_INTERACTIVE") +} + +// Test seam: cmd_test.go reassigns this to stub the remote artifact fetch. +// Production wiring always leaves it pointing at workload.GetArtifact. +var getArtifactFn = workload.GetArtifact + +// Cmd returns the cobra.Command for `dr artifact code doctor`. It inherits the +// artifact tree's DATAROBOT_CLI_FEATURE_WORKLOAD gate from its parent. +func Cmd() *cobra.Command { + var outputFormat outputformat.OutputFormat + + c := &cobra.Command{ + Use: "doctor", + Short: "Diagnose the artifact-code sync state of a project directory.", + SilenceUsage: true, + Args: cobra.NoArgs, + Long: `Diagnose the '.datarobot/workload/' sync state of a project +directory without changing anything. + +The doctor inspects the local sync state (linked artifact, config and +manifest health, config/manifest agreement, interrupted rollbacks, and the +sync lock) and reports each check as OK, WARN, FAIL, or SKIP with a concrete +remedy for anything that needs attention. It is a read-only diagnostic: no +prompt is issued, no file is written, and no remote call is made unless +remote checks apply. + +Pass --fix to attempt the safe local repairs (rebuild the manifest from +config, restore an interrupted rollback, clear a stale sync lock), then +re-run every check and report the post-fix state. Nothing is ever written to +the server, and a live sync holding the lock gates all repairs. --fix and +--relink are mutually exclusive. + +Pass --relink to repoint the project at a different +artifact with a fresh sync baseline. The target must exist, be a draft +(not locked), and be a service-type artifact. An interactive confirm prompt +defaults to No (use --yes to skip it); the working tree is never touched and +no server writes are made. The relink is logged to history.log. + +Exit code is 0 when no check FAILs (warnings are allowed) and 1 when at +least one check FAILs. Pass --output-format json for a machine-parseable +report on stdout. + +Example: + dr artifact code doctor + dr artifact code doctor --dir ./service + dr artifact code doctor --fix + dr artifact code doctor --relink + dr artifact code doctor --output-format json`, + // No PreRunE on purpose: a read-only diagnostic must never abort on + // auth or launch the interactive login wizard. Auth is probed softly + // inside RunE instead (see softAuthProbe). + RunE: func(cmd *cobra.Command, _ []string) error { + outputFormat = outputformat.GetFormat(cmd) + + return pageDoctor(cmd, outputFormat) + }, + } + + outputformat.AddFlag(c, &outputFormat) + + c.Flags().String("dir", ".", "Project directory to diagnose (default: current directory).") + + // Read-only diagnosis never prompts, so --yes changes nothing today; it + // exists so scripts can pass it uniformly and so repair modes added later + // share the same non-interactive switch. + c.Flags().BoolP(cli.YesFlagName, "y", false, "Never prompt (read-only diagnosis never prompts anyway).") + + c.Flags().Bool("fix", false, + "Attempt safe local repairs (rebuild the manifest from config, restore an "+ + "interrupted rollback, clear a stale sync lock), then re-run the checks.") + + c.Flags().String("relink", "", + "Repoint the project at with a fresh sync baseline. "+ + "The target must exist, be a draft, and be a service-type artifact. "+ + "Mutually exclusive with --fix.") + + c.MarkFlagsMutuallyExclusive("fix", "relink") + + telemetry.TrackWith(c, func(cmd *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "yes": cli.IsNonInteractive(cmd), + "output_format": string(outputFormat), + "fix": fixFlagChanged(cmd), + "relink": relinkFlagChanged(cmd), + } + }) + + return c +} + +// pageDoctor executes one diagnosis: resolve the project directory, run the +// check suite, render the report, and exit 1 iff any check FAILed. With +// --fix, the safe local repairs run first and the reported checks (and exit +// code) reflect the POST-fix state. With --relink, the project is repointed +// at a new artifact (fresh BASE reset) before the checks re-run. The rendered +// report is the user-facing outcome, so a FAIL run returns cli.ErrSilent +// (with SilenceErrors set) instead of a second cobra error line. +func pageDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error { + fix, _ := cmd.Flags().GetBool("fix") + + relinkID, _ := cmd.Flags().GetString("relink") + + if err := validateRepairFlags(cmd, fix, relinkID); err != nil { + return err + } + + dirFlag, _ := cmd.Flags().GetString("dir") + + projectDir, err := resolveProjectDir(dirFlag) + if err != nil { + return err + } + + actions, relinkErr := runRepairPhase(cmd, projectDir, fix, relinkID) + + // Soft auth probe: resolve remote credentials without prompting and + // without writing any config file. Local checks never need auth; the + // remote checks (wired by their own feature) will SKIP with a + // connectivity remedy when no usable credentials are found. + creds, authed := softAuthProbe() + + if authed { + log.Debug("doctor resolved remote credentials", "endpoint", creds.Endpoint) + } else { + log.Debug("doctor found no remote credentials; remote checks will report SKIP") + } + + // The complete check suite in the pinned fixed order: six local checks + // then the four remote checks. The remote checks share one artifact + // fetch through the getArtifactFn seam. After --fix/--relink this is the + // post-repair state, so both the report and the exit code describe what + // remains. + results := core.NewRunner( + wldoctor.Checks(projectDir, wldoctor.ArtifactGetterFunc(getArtifactFn))..., + ).Run(cmd.Context()) + + report := core.NewReport(projectDir, linkedArtifactID(projectDir), results) + + report.Actions = actions + + if err := renderReport(cmd, outputFormat, report); err != nil { + return err + } + + // Print relink abort errors to stderr (for not-linked and API-unreachable + // cases the user needs a message; for other aborts the actions array + // already describes the reason). In JSON mode this keeps stdout pure. + if relinkErr != nil && !errors.Is(relinkErr, wldoctor.ErrRelinkAbort) { + fmt.Fprintln(cmd.ErrOrStderr(), relinkErr) + } + + if relinkErr != nil || report.ExitCode() == 1 { + cmd.SilenceErrors = true + + return cli.ErrSilent + } + + return nil +} + +// validateRepairFlags checks the --relink flag for usage errors before any +// work begins. The --fix/--relink mutual exclusion is enforced by cobra's +// MarkFlagsMutuallyExclusive registration, so it is not re-checked here. An +// explicit empty --relink value is a usage error, not a silent read-only +// run — gating on Flags().Changed distinguishes "flag not set" (read-only) +// from "flag set to empty" (usage error). +func validateRepairFlags(cmd *cobra.Command, _ bool, relinkID string) error { + if cmd.Flags().Changed("relink") && relinkID == "" { + return errors.New("--relink requires a non-empty artifact id") + } + + return nil +} + +// runRepairPhase executes the --fix or --relink repair phase and returns the +// actions (nil for read-only runs) and any relink error (nil for --fix and +// read-only runs). The empty-value check for --relink is handled in pageDoctor +// before any work begins, so by the time we get here a changed --relink flag +// always carries a non-empty id. +func runRepairPhase(cmd *cobra.Command, projectDir string, fix bool, relinkID string) (*[]core.Action, error) { + if fix { + performed := wldoctor.RunFix(cmd.Context(), projectDir) + + return &performed, nil + } + + if cmd.Flags().Changed("relink") { + performed, rErr := runRelinkPhase(cmd, projectDir, relinkID) + + if performed != nil { + return &performed, rErr + } + + return nil, rErr + } + + return nil, nil +} + +// renderReport writes the report to stdout as text or JSON. +func renderReport(cmd *cobra.Command, outputFormat outputformat.OutputFormat, report core.Report) error { + out := cmd.OutOrStdout() + + if outputFormat == outputformat.OutputFormatJSON { + return core.WriteJSON(out, report) + } + + return core.WriteText(out, report) +} + +// resolveProjectDir turns the --dir value (or its "." default) into the +// absolute project path used everywhere in the report. filepath.Abs is the +// pinned base behavior; a symlinked final component is resolved to its target +// so a project reached through a link reports the link's destination. +// Intermediate components stay as written, so an OS-level alias the user did +// not create (e.g. macOS /tmp → /private/tmp) never rewrites their path. +// +// Symlink detection uses os.Lstat on the final component rather than a +// basename comparison of the EvalSymlinks result: a symlink whose target +// directory happens to share the link's basename would defeat a basename +// check but is correctly detected by Lstat. +func resolveProjectDir(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve project directory: %w", err) + } + + // Lstat the final component without following it: only a symlink on the + // last element triggers resolution. Intermediate symlinks (e.g. macOS + // /tmp → /private/tmp) are transparently resolved by the OS during Lstat + // but do not cause the final component to be reported as a symlink, so + // the user's path stays as written. A missing or unreadable path keeps + // the Abs result — the checks report the real condition. + info, statErr := os.Lstat(abs) + if statErr != nil || info.Mode()&os.ModeSymlink == 0 { + return abs, nil + } + + resolved, linkErr := filepath.EvalSymlinks(abs) + if linkErr != nil { + return abs, nil + } + + return resolved, nil +} + +// linkedArtifactID reads the linked artifact id from the project's state +// config for the report header. Any read failure or an empty id (empty ≈ nil +// normalization) reports the project as unlinked. +func linkedArtifactID(projectDir string) *string { + cfg, err := wapi.LoadConfig(projectDir) + if err != nil || cfg.ArtifactID == "" { + return nil + } + + id := cfg.ArtifactID + + return &id +} + +// remoteCreds holds non-fatally resolved remote credentials for the doctor's +// remote checks. +type remoteCreds struct { + Endpoint string + Token string +} + +// softAuthProbe resolves remote credentials without prompting the user and +// without writing any configuration file. The doctor is a read-only +// diagnostic, so it must never trigger the interactive login wizard that +// auth.EnsureAuthenticated would and must never touch drconfig.yaml. +// +// Resolution mirrors the CLI's auth precedence: a complete +// DATAROBOT_ENDPOINT/DATAROBOT_API_TOKEN environment pair wins; otherwise the +// stored drconfig.yaml profile is used. A partial env pair is ignored (an +// incomplete pair is an explicit but unusable request). The probe performs no +// network I/O: reachability is judged by the remote checks themselves, which +// report SKIP with a `dr auth login` remedy when these credentials do not +// work. +func softAuthProbe() (remoteCreds, bool) { + env := auth.GetEnvCredentials() + + if env.Endpoint != "" && env.Token != "" { + return remoteCreds{Endpoint: env.Endpoint, Token: env.Token}, true + } + + endpoint := config.GetBaseURL() + token := viperx.GetString(config.DataRobotAPIKey) + + if endpoint == "" || token == "" { + return remoteCreds{}, false + } + + return remoteCreds{Endpoint: endpoint, Token: token}, true +} + +// runRelinkPhase executes the relink operation and returns the actions and +// error. Extracted from pageDoctor to keep cyclomatic complexity manageable. +func runRelinkPhase(cmd *cobra.Command, projectDir, relinkID string) ([]core.Action, error) { + return wldoctor.RunRelink(cmd.Context(), wldoctor.RelinkOptions{ + ProjectDir: projectDir, + NewArtifactID: relinkID, + Store: wldoctor.ArtifactGetterFunc(getArtifactFn), + Confirm: makeRelinkConfirm(cmd), + }) +} + +// makeRelinkConfirm builds the confirm function for the relink operation. +// +// Interactive (TTY, no --yes): the warning and a [y/N] prompt are written to +// stderr; only an explicit "y" or "yes" proceeds (empty Enter declines — this +// is the bespoke default-No prompt, NOT reader.AskYesNo which treats empty +// Enter as Yes). Ctrl-C/EOF at the prompt also declines. +// +// Non-interactive (--yes or non-TTY): the warning is printed to stderr and the +// relink proceeds. In JSON mode this keeps stdout pure (all human text to +// stderr). +func makeRelinkConfirm(cmd *cobra.Command) wldoctor.RelinkConfirmFunc { + nonInteractive := cli.IsNonInteractive(cmd) + + stderr := cmd.ErrOrStderr() + + return func(warning string) bool { + if nonInteractive || !reader.IsStdinTerminal() { + fmt.Fprintln(stderr, warning) + + return true + } + + fmt.Fprintln(stderr, warning) + + fmt.Fprint(stderr, "Proceed? [y/N] ") + + line, err := reader.ReadString() + if err != nil { + // Ctrl-C, EOF, or cancelreader error: treat as decline. + return false + } + + answer := strings.TrimSpace(strings.ToLower(line)) + + return answer == "y" || answer == "yes" + } +} + +// fixFlagChanged reports whether --fix was explicitly set, for telemetry. +func fixFlagChanged(cmd *cobra.Command) bool { + changed, _ := cmd.Flags().GetBool("fix") + + return changed +} + +// relinkFlagChanged reports whether --relink was explicitly set, for telemetry. +func relinkFlagChanged(cmd *cobra.Command) bool { + return cmd.Flags().Changed("relink") +} diff --git a/cmd/artifact/code/doctor/cmd_test.go b/cmd/artifact/code/doctor/cmd_test.go new file mode 100644 index 000000000..6c9b5f6ab --- /dev/null +++ b/cmd/artifact/code/doctor/cmd_test.go @@ -0,0 +1,717 @@ +// 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 doctor + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/datarobot/cli/internal/cli" + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // testArtifactID matches the bare-hex shape of real DataRobot artifact ids. + testArtifactID = "6a90da2ddeadbeefcafe1234" + + // testHash is a syntactically valid SHA-256 hex digest for manifest fixtures. + testHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +// jsonSummary mirrors the pinned summary block of the doctor's JSON report. +type jsonSummary struct { + OK int `json:"ok"` + WARN int `json:"warn"` + FAIL int `json:"fail"` + SKIP int `json:"skip"` +} + +// jsonCheck mirrors one element of the pinned checks array. +type jsonCheck struct { + ID string `json:"id"` + Status string `json:"status"` + Summary string `json:"summary"` + Remedy string `json:"remedy"` + Details map[string]string `json:"details"` + Fixable bool `json:"fixable"` +} + +// jsonReport mirrors the pinned top-level doctor JSON schema. +type jsonReport struct { + ProjectDir string `json:"projectDir"` + ArtifactID *string `json:"artifactId"` + Status string `json:"status"` + Checks []jsonCheck `json:"checks"` + Summary jsonSummary `json:"summary"` +} + +// pinnedCheckOrder is the fixed check order the command must preserve: +// six local checks then the four remote checks (ten total). +var pinnedCheckOrder = []string{ + "wapi.presence", + "wapi.config", + "wapi.manifest", + "wapi.config-manifest-divergence", + "wapi.rollback", + "wapi.lock", + "remote.artifact-exists", + "remote.artifact-locked", + "remote.catalog-mismatch", + "remote.drift", +} + +// withFakeArtifact swaps the command's remote artifact seam for fn, restoring +// the original when the test ends. +func withFakeArtifact(t *testing.T, fn func(string) (*workload.Artifact, error)) { + t.Helper() + + orig := getArtifactFn + + getArtifactFn = fn + + t.Cleanup(func() { getArtifactFn = orig }) +} + +// fakeArtifact builds an artifact fixture with an optional codeRef planted on +// the primary container (mirrors the init command's test helper). +func fakeArtifact(id, name, status string, codeRef *workload.DatarobotCodeRef) *workload.Artifact { + art := &workload.Artifact{ + ID: id, + Name: name, + Status: status, + } + + if codeRef == nil { + return art + } + + primary := true + + art.Spec.ContainerGroups = []workload.ContainerGroup{ + { + Containers: []workload.Container{ + { + Primary: &primary, + + ImageBuildConfig: &workload.ImageBuildConfig{ + CodeRef: &workload.CodeRef{Datarobot: codeRef}, + }, + }, + }, + }, + } + + return art +} + +// newTestCmd builds the doctor command with buffered stdout/stderr and no +// ambient arguments. +func newTestCmd(t *testing.T, args ...string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + + c := Cmd() + + c.SetArgs(args) + + out := &bytes.Buffer{} + + errOut := &bytes.Buffer{} + + c.SetOut(out) + c.SetErr(errOut) + + return c, out, errOut +} + +// writeStateFile writes raw contents to a file inside the project's state +// directory (creating any missing parent directories first). wapi.Dir +// resolves the legacy location when only a legacy directory exists. +func writeStateFile(t *testing.T, projectDir, name, contents string) { + t.Helper() + + target := filepath.Join(wapi.Dir(projectDir), name) + + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + + require.NoError(t, os.WriteFile(target, []byte(contents), 0o600)) +} + +// linkHealthyProject hand-crafts a fully healthy never-synced state: valid +// config (no catalog pointers) plus a valid manifest (empty BASE). +func linkHealthyProject(t *testing.T, projectDir string) { + t.Helper() + + writeStateFile(t, projectDir, "config.json", + `{"artifactId":"`+testArtifactID+`","createdAt":"2026-01-01T00:00:00Z","cliVersion":"test-version"}`) + + writeStateFile(t, projectDir, "manifest.json", + `{"version":1,"syncedAt":null,"syncedVersionId":null,"files":{"app/main.go":{"hash":"`+testHash+`","size":3}}}`) +} + +// stateFileHashes maps every file under projectDir to its SHA-256 hex digest, +// so tests can prove a read-only run wrote nothing and created no files. +// Reads go through an os.Root scoped to projectDir so the walk cannot be +// raced into following a symlink outside the project. +func stateFileHashes(t *testing.T, projectDir string) map[string]string { + t.Helper() + + root, err := os.OpenRoot(projectDir) + + require.NoError(t, err) + + defer func() { + if closeErr := root.Close(); closeErr != nil { + t.Logf("close project root: %v", closeErr) + } + }() + + hashes := map[string]string{} + + err = filepath.WalkDir(projectDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + rel, err := filepath.Rel(projectDir, path) + if err != nil { + return err + } + + f, err := root.Open(rel) + if err != nil { + return err + } + + data, err := io.ReadAll(f) + + if closeErr := f.Close(); closeErr != nil { + return closeErr + } + + if err != nil { + return err + } + + sum := sha256.Sum256(data) + + hashes[path] = hex.EncodeToString(sum[:]) + + return nil + }) + + require.NoError(t, err) + + return hashes +} + +// mustRun executes the command and returns its rendered stdout. +func mustRun(t *testing.T, c *cobra.Command, out *bytes.Buffer) string { + t.Helper() + + require.NoError(t, c.Execute()) + + return out.String() +} + +func TestRunE_HealthyProject_TextReport_ExitZero(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + // A never-synced draft (no codeRef) matches the never-synced state files: + // every check, local and remote, must be OK. + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp) + + outStr := mustRun(t, c, out) + + assert.Empty(t, errOut.String()) + assert.Contains(t, outStr, tmp, "header names the absolute project dir") + assert.Contains(t, outStr, testArtifactID, "header names the linked artifact") + assert.Contains(t, outStr, "CHECK") + assert.Contains(t, outStr, "STATUS") + assert.Contains(t, outStr, "DETAIL") + + for _, id := range pinnedCheckOrder { + assert.Contains(t, outStr, id, "renders check row %s", id) + } + + assert.Contains(t, outStr, "Summary: 10 ok, 0 warn, 0 fail, 0 skip — verdict: ok") +} + +func TestRunE_UnlinkedProject_RendersReport_ExitsOneSilently(t *testing.T) { + tmp := t.TempDir() + + c, out, errOut := newTestCmd(t, "--dir", tmp) + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "any FAIL exits 1 via the silent sentinel") + + assert.Contains(t, out.String(), "wapi.presence") + assert.Contains(t, out.String(), "FAIL") + assert.Contains(t, out.String(), "dr artifact code init ") + assert.NotContains(t, errOut.String(), "Error:", "no cobra error echo after the rendered report") +} + +func TestRunE_HealthyProject_JSONReport(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil + }) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--output-format", "json") + + outStr := mustRun(t, c, out) + + var report jsonReport + + require.NoError(t, json.Unmarshal([]byte(outStr), &report), "stdout is a single pure-JSON object") + + assert.Equal(t, tmp, report.ProjectDir) + require.NotNil(t, report.ArtifactID) + assert.Equal(t, testArtifactID, *report.ArtifactID) + assert.Equal(t, "ok", report.Status) + require.Len(t, report.Checks, len(pinnedCheckOrder)) + + gotOrder := make([]string, 0, len(report.Checks)) + + for _, check := range report.Checks { + gotOrder = append(gotOrder, check.ID) + assert.Equal(t, "OK", check.Status, "check %s", check.ID) + } + + assert.Equal(t, pinnedCheckOrder, gotOrder) + assert.Equal(t, jsonSummary{OK: 10}, report.Summary) +} + +func TestRunE_JSONOutput_CorruptConfig_FailWithPath(t *testing.T) { + tmp := t.TempDir() + + writeStateFile(t, tmp, "config.json", `{"artifactId":"abc`) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent) + + var report jsonReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report), "stdout stays pure JSON on failure") + + assert.Equal(t, "fail", report.Status) + + byID := make(map[string]jsonCheck, len(report.Checks)) + + for _, check := range report.Checks { + byID[check.ID] = check + } + + cfg := byID["wapi.config"] + + assert.Equal(t, "FAIL", cfg.Status) + + wantPath, err := filepath.Abs(filepath.Join(wapi.Dir(tmp), "config.json")) + + require.NoError(t, err) + + assert.Equal(t, wantPath, cfg.Details["path"]) + assert.Nil(t, report.ArtifactID, "unreadable config reports artifactId null") +} + +func TestRunE_DirDefaultsToCwdWithoutPrompting(t *testing.T) { + tmp := t.TempDir() + + t.Chdir(tmp) + + linkHealthyProject(t, tmp) + + // No --dir, no --yes: the doctor must diagnose cwd without prompting. + c, out, errOut := newTestCmd(t, "--output-format", "json") + + c.SetIn(strings.NewReader("")) + + outStr := mustRun(t, c, out) + + assert.NotContains(t, outStr+errOut.String(), "Project directory", "never reuses the dirprompt") + + var report jsonReport + + require.NoError(t, json.Unmarshal([]byte(outStr), &report)) + + want, err := filepath.EvalSymlinks(tmp) + + require.NoError(t, err) + + got, err := filepath.EvalSymlinks(report.ProjectDir) + + require.NoError(t, err) + + assert.Equal(t, want, got, "projectDir is the absolute cwd") +} + +func TestRunE_RelativeDirResolvedAbsolute(t *testing.T) { + tmp := t.TempDir() + + t.Chdir(tmp) + + sub := filepath.Join(tmp, "sub") + + linkHealthyProject(t, sub) + + c, out, _ := newTestCmd(t, "--dir", "sub", "--output-format", "json") + + outStr := mustRun(t, c, out) + + var report jsonReport + + require.NoError(t, json.Unmarshal([]byte(outStr), &report)) + + assert.True(t, filepath.IsAbs(report.ProjectDir), "projectDir must be absolute, got %q", report.ProjectDir) + assert.True(t, strings.HasSuffix(report.ProjectDir, "sub")) + assert.Equal(t, "ok", report.Status) +} + +func TestRunE_SymlinkedDirResolvesToTarget(t *testing.T) { + tmp := t.TempDir() + + target := filepath.Join(tmp, "real") + + link := filepath.Join(tmp, "link") + + linkHealthyProject(t, target) + + require.NoError(t, os.Symlink(target, link)) + + c, out, _ := newTestCmd(t, "--dir", link, "--output-format", "json") + + outStr := mustRun(t, c, out) + + var report jsonReport + + require.NoError(t, json.Unmarshal([]byte(outStr), &report)) + + want, err := filepath.EvalSymlinks(target) + + require.NoError(t, err) + + assert.Equal(t, want, report.ProjectDir, "a symlinked --dir reports its target") +} + +// TestRunE_SymlinkedDirSameBasenameResolvesToTarget pins the fix for the +// basename-only heuristic: a symlink whose target directory shares the link's +// own basename must still be detected and resolved. The old +// filepath.Base(resolved) == filepath.Base(abs) check would treat this as a +// non-symlink because both basename components are "project". +func TestRunE_SymlinkedDirSameBasenameResolvesToTarget(t *testing.T) { + tmp := t.TempDir() + + // Target directory shares the basename "project" with the link itself. + target := filepath.Join(tmp, "data", "project") + + link := filepath.Join(tmp, "project") + + // linkHealthyProject creates the target dir (via MkdirAll in + // writeStateFile) and writes valid state files into it. + linkHealthyProject(t, target) + + require.NoError(t, os.Symlink(target, link)) + + c, out, _ := newTestCmd(t, "--dir", link, "--output-format", "json") + + outStr := mustRun(t, c, out) + + var report jsonReport + + require.NoError(t, json.Unmarshal([]byte(outStr), &report)) + + want, err := filepath.EvalSymlinks(target) + + require.NoError(t, err) + + assert.Equal(t, want, report.ProjectDir, + "a symlink whose target shares its basename still resolves to the target") +} + +func TestRunE_ReadOnlyRun_WritesNothing(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + // An interrupted-rollback FAIL plus the never-created sync.lock both prove + // the read-only guarantee at the command layer. + writeStateFile(t, tmp, ".rollback/stash/app.txt", "backed up") + + before := stateFileHashes(t, tmp) + + c, _, _ := newTestCmd(t, "--dir", tmp) + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "rollback FAIL drives exit 1") + + assert.Equal(t, before, stateFileHashes(t, tmp), "read-only run changed no file") + + _, statErr := os.Stat(filepath.Join(wapi.Dir(tmp), "sync.lock")) + + assert.True(t, os.IsNotExist(statErr), "sync.lock must not be created") +} + +func TestCmd_FlagShape(t *testing.T) { + c := Cmd() + + dirFlag := c.Flags().Lookup("dir") + + require.NotNil(t, dirFlag) + assert.Equal(t, ".", dirFlag.DefValue, "--dir defaults to the current directory") + + yesFlag := c.Flags().Lookup(cli.YesFlagName) + + require.NotNil(t, yesFlag) + assert.Equal(t, "y", yesFlag.Shorthand) + + assert.NotNil(t, c.Flags().Lookup("output-format")) + + assert.Contains(t, c.Annotations, "telemetry", "tracked via telemetry.TrackWith like siblings") +} + +func TestCmd_RejectsPositionalArgs(t *testing.T) { + c := Cmd() + + c.SetArgs([]string{"unexpected"}) + + assert.Error(t, c.Execute()) +} + +func TestRunE_Remote404_DeletedArtifact_FailWithRelinkRemedy(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "https://test/artifacts/x/"} + }) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "a remote FAIL drives exit 1") + + var report jsonReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report), "stdout stays pure JSON on failure") + + require.Len(t, report.Checks, len(pinnedCheckOrder)) + + byID := make(map[string]jsonCheck, len(report.Checks)) + + for _, check := range report.Checks { + byID[check.ID] = check + } + + exists := byID["remote.artifact-exists"] + + assert.Equal(t, "FAIL", exists.Status) + assert.Contains(t, exists.Summary, "deleted") + assert.Contains(t, exists.Remedy, "--relink") + + // The dependent remote checks SKIP rather than pile on their own FAILs. + for _, id := range []string{"remote.artifact-locked", "remote.catalog-mismatch", "remote.drift"} { + assert.Equal(t, "SKIP", byID[id].Status, "check %s", id) + } + + assert.Equal(t, "fail", report.Status) +} + +func TestRunE_RemoteNon404_AllSkipWithConnectivityRemedy_ExitZero(t *testing.T) { + for name, remoteErr := range map[string]error{ + "500": &drapi.HTTPError{StatusCode: 500, URL: "https://test/"}, + "unauthorized": &drapi.HTTPError{StatusCode: 401, URL: "https://test/"}, + "conn-refused": errors.New("dial tcp 127.0.0.1:443: connect: connection refused"), + "wrapped-non-404": fmt.Errorf("fetch: %w", &drapi.HTTPError{StatusCode: 503, URL: "https://test/"}), + } { + t.Run(name, func(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, remoteErr + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--output-format", "json") + + require.NoError(t, c.Execute(), "non-404 remote errors never FAIL the run") + + var report jsonReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report), "stdout stays pure JSON") + + byID := make(map[string]jsonCheck, len(report.Checks)) + + for _, check := range report.Checks { + byID[check.ID] = check + + if strings.HasPrefix(check.ID, "remote.") { + assert.Equal(t, "SKIP", check.Status, "check %s: summary %q", check.ID, check.Summary) + + assert.NotContains(t, check.Summary, "deleted", "non-404 is never reported as deleted") + + assert.Contains(t, check.Remedy, "auth login") + + assert.NotContains(t, check.Remedy, "--relink") + } else { + assert.Equal(t, "OK", check.Status, "local checks unaffected by remote failure: %s", check.ID) + } + } + + assert.Equal(t, jsonSummary{OK: 6, SKIP: 4}, report.Summary) + + assert.Equal(t, "ok", report.Status, "SKIP-only remote outcome keeps verdict ok") + + assert.Empty(t, errOut.String()) + }) + } +} + +func TestRunE_RemoteLockedArtifact_WarnNeverFail_ExitZero(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "doctor-fixture", "locked", nil), nil + }) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--output-format", "json") + + require.NoError(t, c.Execute(), "a WARN alone must not fail the run") + + var report jsonReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + + byID := make(map[string]jsonCheck, len(report.Checks)) + + for _, check := range report.Checks { + byID[check.ID] = check + } + + locked := byID["remote.artifact-locked"] + + assert.Equal(t, "WARN", locked.Status, "locked must WARN, never FAIL") + assert.False(t, locked.Fixable) + assert.Contains(t, locked.Summary, "preview") + assert.Contains(t, locked.Summary, "execute") + assert.Equal(t, "warn", report.Status) +} + +func TestRunE_RemoteChecks_SingleArtifactFetchPerRun(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + calls := 0 + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + calls++ + + return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil + }) + + c, _, _ := newTestCmd(t, "--dir", tmp) + + require.NoError(t, c.Execute()) + + assert.Equal(t, 1, calls, "all four remote checks share one artifact fetch per run") +} + +func TestSoftAuthProbe(t *testing.T) { + // Neutralize any inherited environment so each case starts from a known + // state; t.Setenv restores them after the test. + t.Setenv("DATAROBOT_ENDPOINT", "") + t.Setenv("DATAROBOT_API_ENDPOINT", "") + t.Setenv("DATAROBOT_API_TOKEN", "") + + t.Run("complete env pair wins", func(t *testing.T) { + t.Setenv("DATAROBOT_ENDPOINT", "https://env.example.com/api/v2") + t.Setenv("DATAROBOT_API_TOKEN", "env-token") + + creds, ok := softAuthProbe() + + assert.True(t, ok) + assert.Equal(t, "https://env.example.com/api/v2", creds.Endpoint) + assert.Equal(t, "env-token", creds.Token) + }) + + t.Run("partial env pair is ignored", func(t *testing.T) { + t.Setenv("DATAROBOT_API_TOKEN", "env-token") + + _, ok := softAuthProbe() + + assert.False(t, ok, "a lone token must not count as remote access") + }) + + t.Run("stored config used when env is silent", func(t *testing.T) { + viperx.Set(config.DataRobotURL, "https://stored.example.com/api/v2") + viperx.Set(config.DataRobotAPIKey, "stored-token") + + t.Cleanup(func() { + viperx.Set(config.DataRobotURL, "") + viperx.Set(config.DataRobotAPIKey, "") + }) + + creds, ok := softAuthProbe() + + assert.True(t, ok) + assert.Equal(t, "https://stored.example.com", creds.Endpoint) + assert.Equal(t, "stored-token", creds.Token) + }) + + t.Run("nothing available", func(t *testing.T) { + _, ok := softAuthProbe() + + assert.False(t, ok) + }) +} diff --git a/cmd/artifact/code/doctor/fix_cmd_test.go b/cmd/artifact/code/doctor/fix_cmd_test.go new file mode 100644 index 000000000..bae26cb85 --- /dev/null +++ b/cmd/artifact/code/doctor/fix_cmd_test.go @@ -0,0 +1,401 @@ +// 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 doctor + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/datarobot/cli/internal/cli" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeValidConfig writes a hand-crafted config.json that passes wapi +// validation (a linked, never-synced draft) without touching the manifest. +func writeValidConfig(t *testing.T, projectDir string) { + t.Helper() + + writeStateFile(t, projectDir, "config.json", + `{"artifactId":"`+testArtifactID+`","createdAt":"2026-01-01T00:00:00Z","cliVersion":"test-version"}`) +} + +// jsonAction mirrors one element of the pinned actions array: +// {id, status, reason}. +type jsonAction struct { + ID string `json:"id"` + Status string `json:"status"` + Reason string `json:"reason"` +} + +// jsonFixReport mirrors the doctor JSON report for repair runs: the base +// report plus the actions array (embedded so JSON keys flatten). +type jsonFixReport struct { + jsonReport + + Actions []jsonAction `json:"actions"` +} + +// TestRunE_FixHealthyProject_NothingToDo_ExitZero verifies --fix on a healthy +// project is a no-op whose text output says "nothing to do" explicitly and +// exits 0. +func TestRunE_FixHealthyProject_NothingToDo_ExitZero(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--fix") + + outStr := mustRun(t, c, out) + + assert.Empty(t, errOut.String()) + assert.Contains(t, outStr, "Repairs") + assert.Contains(t, outStr, "nothing to fix") + assert.Contains(t, outStr, "verdict: ok") +} + +// TestRunE_FixMissingManifest_PostFixOK_ExitZero verifies at the command +// surface the repair is performed, the post-fix check suite reports the +// manifest OK, the exit code is 0, and the JSON stdout is pure with a +// pinned-shape actions array. +func TestRunE_FixMissingManifest_PostFixOK_ExitZero(t *testing.T) { + tmp := t.TempDir() + + // Valid config, no manifest.json: the rebuild must repair it. + writeValidConfig(t, tmp) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--fix", "--output-format", "json") + + outStr := mustRun(t, c, out) + + assert.Empty(t, errOut.String()) + + var report jsonFixReport + + require.NoError(t, json.Unmarshal([]byte(outStr), &report), "stdout must be a single pure-JSON object") + + assert.Equal(t, "ok", report.Status, "post-fix state is healthy") + assert.Equal(t, 10, report.Summary.OK) + + require.Len(t, report.Actions, 3) + + assert.Equal(t, "wapi.manifest", report.Actions[0].ID) + assert.Equal(t, "performed", report.Actions[0].Status) + assert.Equal(t, "wapi.rollback", report.Actions[1].ID) + assert.Equal(t, "wapi.lock", report.Actions[2].ID) + + for _, check := range report.Checks { + assert.Equal(t, "OK", check.Status, "post-fix check %s", check.ID) + } + + // The rebuilt manifest parses and is an empty BASE (both-or-neither). + m, err := wapi.LoadManifest(tmp) + + require.NoError(t, err) + + assert.Empty(t, m.Files) + assert.Nil(t, m.SyncedVersionID) + assert.Nil(t, m.SyncedAt) +} + +// TestRunE_FixCorruptConfig_ManifestSkipped_ExitOne verifies a corrupt config +// makes the manifest rebuild skip with a re-init remedy, the unfixable FAIL +// keeps exit 1, and stdout stays pure JSON on the failure path. +func TestRunE_FixCorruptConfig_ManifestSkipped_ExitOne(t *testing.T) { + tmp := t.TempDir() + + writeStateFile(t, tmp, "config.json", `{"artifactId":"abc`) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--fix", "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "an unfixable FAIL keeps exit 1") + + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report), "stdout stays pure JSON on failure") + + assert.Equal(t, "fail", report.Status) + + require.NotEmpty(t, report.Actions) + + rebuild := report.Actions[0] + + assert.Equal(t, "wapi.manifest", rebuild.ID) + assert.Equal(t, "skipped", rebuild.Status) + assert.Contains(t, rebuild.Reason, "config") + assert.Contains(t, rebuild.Reason, "init", "the skip reason must carry the re-init remedy") +} + +// TestRunE_FixHeldLock_AllSkipped_ExitOne verifies at the command surface a +// live holder gates the whole run — every repair is skipped with the +// sync-in-progress reason, nothing is written, and the still-held lock keeps +// exit 1. +func TestRunE_FixHeldLock_AllSkipped_ExitOne(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("flock semantics are unix-only; the windows gate path is covered by the seam tests") + } + + tmp := t.TempDir() + + writeValidConfig(t, tmp) + + // A repairable problem (missing manifest) that must NOT be repaired. + lockFile := filepath.Join(wapi.Dir(tmp), "sync.lock") + + require.NoError(t, os.WriteFile(lockFile, nil, 0o600)) + + release := holdSyncLock(t, lockFile) + + defer release() + + c, out, _ := newTestCmd(t, "--dir", tmp, "--fix", "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "the still-held lock keeps exit 1") + + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + + require.Len(t, report.Actions, 3) + + for _, action := range report.Actions { + assert.Equal(t, "skipped", action.Status, "action %s", action.ID) + assert.Contains(t, action.Reason, "sync in progress", "action %s", action.ID) + } + + _, statErr := os.Stat(filepath.Join(wapi.Dir(tmp), "manifest.json")) + + require.ErrorIs(t, statErr, os.ErrNotExist, "the manifest must NOT be rebuilt under a live sync") + + locked := make(map[string]jsonCheck, len(report.Checks)) + + for _, check := range report.Checks { + locked[check.ID] = check + } + + assert.Equal(t, "FAIL", locked["wapi.lock"].Status, "the post-fix suite still reports the held lock") +} + +// TestRunE_FixRollbackRestoresFiles_TextActions verifies at the command +// surface the restore lands on disk and the text report shows the performed +// action. +func TestRunE_FixRollbackRestoresFiles_TextActions(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + backup := filepath.Join(wapi.Dir(tmp), ".rollback", "app", "main.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(backup), 0o755)) + + require.NoError(t, os.WriteFile(backup, []byte("backed up contents"), 0o600)) + + working := filepath.Join(tmp, "app", "main.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(working), 0o755)) + + require.NoError(t, os.WriteFile(working, []byte("drifted contents"), 0o600)) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--fix") + + outStr := mustRun(t, c, out) + + assert.Empty(t, errOut.String()) + assert.Contains(t, outStr, "wapi.rollback: performed") + + restored, err := os.ReadFile(working) + + require.NoError(t, err) + + assert.Equal(t, "backed up contents", string(restored)) + + _, statErr := os.Stat(filepath.Join(wapi.Dir(tmp), ".rollback")) + + assert.ErrorIs(t, statErr, os.ErrNotExist, ".rollback/ must be removed after the restore") +} + +// TestRunE_FixRollbackRecreatesDeletedProjectFile verifies end to end a file +// the interrupted sync deleted comes back from the backup tree. +func TestRunE_FixRollbackRecreatesDeletedProjectFile(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + backup := filepath.Join(wapi.Dir(tmp), ".rollback", "app", "removed.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(backup), 0o755)) + + require.NoError(t, os.WriteFile(backup, []byte("resurrected"), 0o600)) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil + }) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--fix") + + require.NoError(t, c.Execute()) + + restored, err := os.ReadFile(filepath.Join(tmp, "app", "removed.go")) + + require.NoError(t, err) + + assert.Equal(t, "resurrected", string(restored)) + assert.Contains(t, out.String(), "performed") +} + +// TestRunE_FixSecondRunIsNoop verifies at the command surface after one +// successful fix, a second --fix run reports nothing to do and exits 0. +func TestRunE_FixSecondRunIsNoop(t *testing.T) { + tmp := t.TempDir() + + writeValidConfig(t, tmp) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil + }) + + first, _, _ := newTestCmd(t, "--dir", tmp, "--fix") + + require.NoError(t, first.Execute()) + + second, out, _ := newTestCmd(t, "--dir", tmp, "--fix", "--output-format", "json") + + require.NoError(t, second.Execute()) + + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + + assert.Equal(t, "ok", report.Status) + + for _, action := range report.Actions { + assert.Equal(t, "not-needed", action.Status, "action %s", action.ID) + } +} + +// TestRunE_FixAndRelinkMutuallyExclusive verifies the cobra-level mutual +// exclusion: combining --fix and --relink errors before any check runs (the +// remote artifact seam is never called, stdout stays empty) with cobra's +// generic mutually-exclusive message rather than a hand-rolled one. +func TestRunE_FixAndRelinkMutuallyExclusive(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + calls := 0 + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + calls++ + + return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--fix", "--relink", "abc123") + + require.Error(t, c.Execute(), "combining --fix and --relink must be a usage error") + + assert.Contains(t, errOut.String(), + "if any flags in the group [fix relink] are set none of the others can be", + "stderr must carry cobra's generic mutually-exclusive message") + + assert.Empty(t, out.String(), "no report may be rendered for a usage error") + + assert.Zero(t, calls, "no checks may run for a usage error") +} + +// TestCmd_FixFlagShape pins the --fix flag's shape alongside the other flags. +func TestCmd_FixFlagShape(t *testing.T) { + c := Cmd() + + fixFlag := c.Flags().Lookup("fix") + + require.NotNil(t, fixFlag) + assert.Equal(t, "bool", fixFlag.Value.Type()) + assert.Equal(t, "false", fixFlag.DefValue) +} + +// TestRunE_FixDeletedArtifactAndMissingManifest_LocalFixSucceedsRemoteStillFails +// verifies the manifest rebuild (local) succeeds, but the deleted artifact +// (remote) remains FAIL — --fix is local-only and does not relink. +func TestRunE_FixDeletedArtifactAndMissingManifest_LocalFixSucceedsRemoteStillFails(t *testing.T) { + tmp := t.TempDir() + + writeValidConfig(t, tmp) + + // No manifest.json: the rebuild will repair it. + // The artifact is deleted (404): the remote check must remain FAIL. + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "https://test/artifacts/x/"} + }) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--fix", "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "the deleted artifact keeps exit 1 after the local fix") + + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report), "stdout stays pure JSON") + + assert.Equal(t, "fail", report.Status, "post-fix status is still fail (remote FAIL)") + + // The manifest rebuild was performed. + require.NotEmpty(t, report.Actions) + + rebuild := report.Actions[0] + + assert.Equal(t, "wapi.manifest", rebuild.ID) + assert.Equal(t, "performed", rebuild.Status) + + // The remote artifact-exists check is still FAIL with a relink remedy. + byID := make(map[string]jsonCheck, len(report.Checks)) + + for _, check := range report.Checks { + byID[check.ID] = check + } + + exists := byID["remote.artifact-exists"] + + assert.Equal(t, "FAIL", exists.Status) + assert.Contains(t, exists.Remedy, "--relink") + + // The local manifest check is now OK. + manifest := byID["wapi.manifest"] + + assert.Equal(t, "OK", manifest.Status) +} diff --git a/cmd/artifact/code/doctor/heldlock_unix_test.go b/cmd/artifact/code/doctor/heldlock_unix_test.go new file mode 100644 index 000000000..2c0ea0f82 --- /dev/null +++ b/cmd/artifact/code/doctor/heldlock_unix_test.go @@ -0,0 +1,45 @@ +// 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. + +//go:build !windows + +package doctor + +import ( + "os" + "testing" + + "golang.org/x/sys/unix" + + "github.com/stretchr/testify/require" +) + +// holdSyncLock acquires the same exclusive advisory lock the sync engine +// uses, from this test process, to simulate a live second CLI process +// holding the sync lock. The returned function releases it. +func holdSyncLock(t *testing.T, path string) func() { + t.Helper() + + f, err := os.OpenFile(path, os.O_RDWR, 0o600) + + require.NoError(t, err) + + require.NoError(t, unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)) //nolint:gosec // uintptr and int are same size on supported platforms + + return func() { + _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) //nolint:gosec // uintptr and int are same size on supported platforms + + _ = f.Close() + } +} diff --git a/cmd/artifact/code/doctor/heldlock_windows_test.go b/cmd/artifact/code/doctor/heldlock_windows_test.go new file mode 100644 index 000000000..eb5e529b1 --- /dev/null +++ b/cmd/artifact/code/doctor/heldlock_windows_test.go @@ -0,0 +1,26 @@ +// 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. + +//go:build windows + +package doctor + +import "testing" + +// holdSyncLock on Windows cannot fabricate a live holder: real LockFileEx +// support is tracked in RAPTOR-16928 and the lock check reports SKIP there. +// Tests that need a live holder skip on windows via this seam. +func holdSyncLock(_ *testing.T, _ string) func() { + panic("holdSyncLock is unix-only; callers must skip on GOOS=windows before calling it") +} diff --git a/cmd/artifact/code/doctor/relink_cmd_test.go b/cmd/artifact/code/doctor/relink_cmd_test.go new file mode 100644 index 000000000..764e1e006 --- /dev/null +++ b/cmd/artifact/code/doctor/relink_cmd_test.go @@ -0,0 +1,625 @@ +// 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 doctor + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/datarobot/cli/internal/cli" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRunE_RelinkHappyPath_PostRelinkChecksTargetNew verifies at the command +// surface relink to a new live artifact, post-relink checks target the new +// artifact, exit 0. +func TestRunE_RelinkHappyPath_PostRelinkChecksTargetNew(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + if id == newID { + return fakeArtifact(id, "new-fixture", "DRAFT", nil), nil + } + + // Old artifact still exists (healthy scenario). + return fakeArtifact(id, "old-fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes", "--output-format", "json") + + outStr := mustRun(t, c, out) + + // Stdout is pure JSON (warning goes to stderr). + var report jsonFixReport + + require.NoError(t, json.Unmarshal([]byte(outStr), &report), "stdout must be a single pure-JSON object") + + // Warning on stderr. + assert.Contains(t, errOut.String(), "Relink repoints") + + // Actions array has the relink entry. + require.NotEmpty(t, report.Actions) + + relinkAction := report.Actions[0] + + assert.Equal(t, "relink", relinkAction.ID) + assert.Equal(t, "performed", relinkAction.Status) + + // Post-relink checks target the new artifact (all OK). + assert.Equal(t, "ok", report.Status) + + require.NotNil(t, report.ArtifactID) + + assert.Equal(t, newID, *report.ArtifactID, "artifactId in the report is the new id") + + // Config on disk points at the new artifact. + cfg, err := wapi.LoadConfig(tmp) + + require.NoError(t, err) + + assert.Equal(t, newID, cfg.ArtifactID) + assert.Nil(t, cfg.LastSyncedVersionID) +} + +// TestRunE_RelinkNonInteractiveWarning_ToStderr verifies --yes prints the +// warning to stderr and proceeds; stdout stays pure JSON. +func TestRunE_RelinkNonInteractiveWarning_ToStderr(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes", "--output-format", "json") + + require.NoError(t, c.Execute()) + + // Stdout is pure JSON. + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + + // Stderr contains the warning. + assert.Contains(t, errOut.String(), "Relink repoints") +} + +// TestRunE_RelinkNonInteractiveText_WarningToStderr verifies in text mode the +// warning goes to stderr. +func TestRunE_RelinkNonInteractiveText_WarningToStderr(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes") + + require.NoError(t, c.Execute()) + + assert.Contains(t, errOut.String(), "Relink repoints") + assert.Contains(t, out.String(), "Repairs") + assert.Contains(t, out.String(), "relink: performed") +} + +// TestRunE_Relink404_AbortsStateUntouched verifies at the command surface a +// 404 target aborts with exit 1 and state untouched. +func TestRunE_Relink404_AbortsStateUntouched(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "https://test/artifacts/x/"} + }) + + before := stateFileHashes(t, tmp) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes", "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "404 abort exits 1") + + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report), "stdout stays pure JSON") + + assert.Equal(t, "fail", report.Status) + + require.NotEmpty(t, report.Actions) + + assert.Equal(t, "relink", report.Actions[0].ID) + assert.Equal(t, "skipped", report.Actions[0].Status) + assert.Contains(t, report.Actions[0].Reason, "not found") + + // State byte-identical. + assert.Equal(t, before, stateFileHashes(t, tmp)) +} + +// TestRunE_RelinkNotLinked_ErrorPointsToInit verifies at the command surface +// relink on a not-linked project exits 1 with presence FAIL. +func TestRunE_RelinkNotLinked_ErrorPointsToInit(t *testing.T) { + tmp := t.TempDir() + + calls := 0 + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + calls++ + + return fakeArtifact(newID, "fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes", "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "not-linked exits 1") + + // No network fetch (short-circuit before fetch). + assert.Zero(t, calls, "no network fetch for a not-linked project") + + // The report shows presence FAIL. + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + + byID := make(map[string]jsonCheck, len(report.Checks)) + + for _, check := range report.Checks { + byID[check.ID] = check + } + + assert.Equal(t, "FAIL", byID["wapi.presence"].Status) + assert.Contains(t, byID["wapi.presence"].Remedy, "init") + + // Stderr has the error message. + assert.Contains(t, errOut.String(), "not linked") +} + +// TestRunE_RelinkSameID_WarnedBaseReset verifies at the command surface +// same-id relink is allowed, warned, and resets BASE. +func TestRunE_RelinkSameID_WarnedBaseReset(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--relink", testArtifactID, "--yes") + + require.NoError(t, c.Execute()) + + // Warning includes the same-id note. + assert.Contains(t, errOut.String(), "same artifact") + + // Config: artifactId unchanged, lsv nil. + cfg, err := wapi.LoadConfig(tmp) + + require.NoError(t, err) + + assert.Equal(t, testArtifactID, cfg.ArtifactID) + assert.Nil(t, cfg.LastSyncedVersionID) + + // Manifest: empty BASE. + m, err := wapi.LoadManifest(tmp) + + require.NoError(t, err) + + assert.Empty(t, m.Files) + + // History: relink entry with from == to. + assert.Contains(t, out.String(), "performed") +} + +// TestRunE_RelinkJSON_ActionsArray_PureStdout verifies JSON mode has an actions +// array describing the relink, stdout pure, warning on stderr. +func TestRunE_RelinkJSON_ActionsArray_PureStdout(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "fixture", "DRAFT", nil), nil + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes", "--output-format", "json") + + require.NoError(t, c.Execute()) + + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + + require.NotEmpty(t, report.Actions) + + assert.Equal(t, "relink", report.Actions[0].ID) + assert.Equal(t, "performed", report.Actions[0].Status) + + // Post-relink checks reflect the new artifact. + byID := make(map[string]jsonCheck, len(report.Checks)) + + for _, check := range report.Checks { + byID[check.ID] = check + } + + assert.Equal(t, "OK", byID["remote.artifact-exists"].Status) + + // Warning on stderr only. + assert.Contains(t, errOut.String(), "Relink repoints") +} + +// TestRunE_RelinkWorkingTreeUntouched verifies the working tree is untouched +// by the relink. +func TestRunE_RelinkWorkingTreeUntouched(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + // Place a working-tree file. + working := filepath.Join(tmp, "app", "main.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(working), 0o755)) + + require.NoError(t, os.WriteFile(working, []byte("user source"), 0o600)) + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "fixture", "DRAFT", nil), nil + }) + + before := stateFileHashes(t, tmp) + + c, _, _ := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes") + + require.NoError(t, c.Execute()) + + after := stateFileHashes(t, tmp) + + // Working-tree files (non-state) are byte-identical. + for path, want := range before { + if isStateFile(path, tmp) { + continue + } + + got, ok := after[path] + + require.True(t, ok, "file disappeared: %s", path) + + assert.Equal(t, want, got, "working-tree file must be untouched: %s", path) + } +} + +// TestRunE_RelinkHistoryEntry verifies history.log gains a well-formed +// {op:relink, from, to, ts} entry. +func TestRunE_RelinkHistoryEntry(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "fixture", "DRAFT", nil), nil + }) + + c, _, _ := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes") + + require.NoError(t, c.Execute()) + + history, err := os.ReadFile(filepath.Join(wapi.Dir(tmp), "history.log")) + + require.NoError(t, err) + + // The last line must be a relink entry. + lines := []string{} + + for _, line := range splitLinesStr(string(history)) { + if line != "" { + lines = append(lines, line) + } + } + + require.NotEmpty(t, lines) + + var entry map[string]any + + require.NoError(t, json.Unmarshal([]byte(lines[len(lines)-1]), &entry)) + + assert.Equal(t, "relink", entry["op"]) + assert.Equal(t, testArtifactID, entry["from"]) + assert.Equal(t, newID, entry["to"]) + + ts, ok := entry["ts"].(string) + + require.True(t, ok, "ts must be a string") + + assert.NotEmpty(t, ts, "ts must be non-empty") +} + +// TestRunE_RelinkLockHeld_AbortsStateUntouched verifies at the command surface +// a held lock aborts the relink with state untouched. +func TestRunE_RelinkLockHeld_AbortsStateUntouched(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("flock semantics are unix-only") + } + + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + lockFile := filepath.Join(wapi.Dir(tmp), "sync.lock") + + require.NoError(t, os.WriteFile(lockFile, nil, 0o600)) + + release := holdSyncLock(t, lockFile) + + defer release() + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "fixture", "DRAFT", nil), nil + }) + + before := stateFileHashes(t, tmp) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes", "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "lock held exits 1") + + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + + require.NotEmpty(t, report.Actions) + + assert.Equal(t, "skipped", report.Actions[0].Status) + assert.Contains(t, report.Actions[0].Reason, "sync in progress") + + // State byte-identical. + assert.Equal(t, before, stateFileHashes(t, tmp)) +} + +// TestRunE_RelinkWrongType_AbortsStateUntouched verifies at the command +// surface a non-service artifact type aborts with state untouched. +func TestRunE_RelinkWrongType_AbortsStateUntouched(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return &workload.Artifact{ + ID: id, + Name: "agent-fixture", + Status: "DRAFT", + Type: "agent", + }, nil + }) + + before := stateFileHashes(t, tmp) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes", "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "wrong type exits 1") + + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + + require.NotEmpty(t, report.Actions) + + assert.Equal(t, "skipped", report.Actions[0].Status) + assert.Contains(t, report.Actions[0].Reason, "agent") + + assert.Equal(t, before, stateFileHashes(t, tmp)) +} + +// TestRunE_RelinkAPIUnreachable_Aborts covers the API unreachable gate at the +// command surface: a non-404 fetch error aborts with an error on stderr. +func TestRunE_RelinkAPIUnreachable_Aborts(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + newID := "6a90da2ddeadbeefcafe5678" + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 500, URL: "https://test/"} + }) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--relink", newID, "--yes", "--output-format", "json") + + err := c.Execute() + + require.ErrorIs(t, err, cli.ErrSilent, "API unreachable exits 1") + + // Stderr has the error message. + assert.Contains(t, errOut.String(), "cannot reach") + + // Stdout is pure JSON (the report still renders). + var report jsonFixReport + + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) +} + +// TestRunE_RelinkNoActionsKey_ReadOnlyRun verifies a plain diagnosis (no +// --fix/--relink) has no actions key. +func TestRunE_RelinkNoActionsKey_ReadOnlyRun(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "fixture", "DRAFT", nil), nil + }) + + c, out, _ := newTestCmd(t, "--dir", tmp, "--output-format", "json") + + require.NoError(t, c.Execute()) + + // Parse as a generic map to check for the actions key. + var raw map[string]any + + require.NoError(t, json.Unmarshal(out.Bytes(), &raw)) + + _, hasActions := raw["actions"] + + assert.False(t, hasActions, "plain diagnosis must not have an actions key") +} + +// TestCmd_RelinkFlagShape pins the --relink flag's shape. +func TestCmd_RelinkFlagShape(t *testing.T) { + c := Cmd() + + relinkFlag := c.Flags().Lookup("relink") + + require.NotNil(t, relinkFlag) + assert.Equal(t, "string", relinkFlag.Value.Type()) + assert.Empty(t, relinkFlag.DefValue, "--relink defaults to empty (not set)") +} + +// TestCmd_FixAndRelinkMutuallyExclusiveShape verifies the cobra-level mutual +// exclusion is registered. +func TestCmd_FixAndRelinkMutuallyExclusiveShape(t *testing.T) { + c := Cmd() + + // MarkFlagsMutuallyExclusive adds a cobra annotation that we can verify + // by checking that both flags exist and the command rejects both. + fixFlag := c.Flags().Lookup("fix") + + relinkFlag := c.Flags().Lookup("relink") + + require.NotNil(t, fixFlag) + require.NotNil(t, relinkFlag) +} + +// TestRunE_RelinkMissingValue_UsageError verifies --relink without a value +// is a usage error. +func TestRunE_RelinkMissingValue_UsageError(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--relink") + + err := c.Execute() + + require.Error(t, err, "missing --relink value must be a usage error") + + // No checks execute, no report on stdout. + assert.Empty(t, out.String(), "no report for a usage error") + + // Stderr has a usage error message. + assert.NotEmpty(t, errOut.String()) +} + +// isStateFile reports whether path is inside the state directory. +func isStateFile(path, projectDir string) bool { + stateDir := wapi.Dir(projectDir) + + return len(path) >= len(stateDir) && path[:len(stateDir)] == stateDir +} + +// splitLinesStr splits on newlines, dropping trailing empty lines. +func splitLinesStr(s string) []string { + var lines []string + + start := 0 + + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + + start = i + 1 + } + } + + if start < len(s) { + lines = append(lines, s[start:]) + } + + return lines +} + +// TestRunE_RelinkEmptyValue_UsageError verifies that --relink "" (an explicit +// empty value) is a usage error (exit 1), not a silent read-only run. The +// repair phase must gate on Flags().Changed so an empty value is rejected. +func TestRunE_RelinkEmptyValue_UsageError(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + c, out, errOut := newTestCmd(t, "--dir", tmp, "--relink", "") + + err := c.Execute() + + require.Error(t, err, "--relink '' must be a usage error") + + assert.Contains(t, errOut.String(), "non-empty artifact id", + "stderr must explain the empty-value rejection") + + // No checks execute, no report on stdout. + assert.Empty(t, out.String(), "no report for a usage error") +} + +// TestRunE_RelinkUnchanged_ReadOnlyRun verifies that a plain read-only run +// (no --relink flag at all) is unaffected by the empty-value gate. +func TestRunE_RelinkUnchanged_ReadOnlyRun(t *testing.T) { + tmp := t.TempDir() + + linkHealthyProject(t, tmp) + + c, out, _ := newTestCmd(t, "--dir", tmp) + + err := c.Execute() + + require.NoError(t, err, "a plain read-only run must succeed") + + assert.NotEmpty(t, out.String(), "the diagnostic report must be rendered") +} diff --git a/cmd/artifact/code/init/cmd.go b/cmd/artifact/code/init/cmd.go index 26b650c1f..3489d658e 100644 --- a/cmd/artifact/code/init/cmd.go +++ b/cmd/artifact/code/init/cmd.go @@ -28,6 +28,7 @@ import ( "github.com/datarobot/cli/internal/outputformat" "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/internal/workload" + wldoctor "github.com/datarobot/cli/internal/workload/doctor" "github.com/datarobot/cli/internal/workload/wapi" "github.com/spf13/cobra" ) @@ -105,7 +106,7 @@ func runInit(cmd *cobra.Command, args []string, outputFormat outputformat.Output format.StateNotice(cmd.ErrOrStderr(), wapi.EnsureMigrated(dir)) if wapi.Exists(dir) { - return reportAlreadyLinked(dir) + return reportAlreadyLinked(cmd, dir, outputFormat) } artifactID, err := dirprompt.ResolveArtifactID(args, yes, dirprompt.Ask) @@ -123,7 +124,7 @@ func runInit(cmd *cobra.Command, args []string, outputFormat outputformat.Output if err := wapi.Initialize(dir, opts); err != nil { if errors.Is(err, wapi.ErrAlreadyLinked) { - return reportAlreadyLinked(dir) + return reportAlreadyLinked(cmd, dir, outputFormat) } return err @@ -162,13 +163,81 @@ func buildInitOptions(artifactID string, codeRef *workload.DatarobotCodeRef) wap return opts } -func reportAlreadyLinked(dir string) error { - cfg, lerr := wapi.LoadConfig(dir) - if lerr != nil { - return fmt.Errorf("project already linked but config is unreadable: %w", lerr) +// reportAlreadyLinked handles the already-linked branch: the project is +// already linked and the user tried to init again. It fetches the linked +// artifact to determine health and branches: +// - Corrupt config (unreadable linked state): report unreadable, remedy +// names doctor --fix, never deletion. +// - Gone (404) or catalog mismatch: interactive → offer to relink in place; +// non-interactive → print guidance naming doctor --relink. +// - Healthy (or non-404 error — can't determine): keep abort behavior, +// point to doctor for diagnosis. No delete advice anywhere. +// +// JSON mode: the abort emits a single JSON object on stdout +// {status:error, error:already-linked, artifactId:, remedy:} +// with human text on stderr, exit 1. +func reportAlreadyLinked(cmd *cobra.Command, dir string, outputFormat outputformat.OutputFormat) error { + stderr := cmd.ErrOrStderr() + + cfg, err := wapi.LoadConfig(dir) + if err != nil { + // Corrupt config: cannot read the linked artifact id. Do NOT fetch; + // report unreadable, remedy names doctor --fix, never deletion. + // The underlying LoadConfig error is wrapped so the user sees the + // root cause (e.g. JSON parse error), and the config path is included + // in both text and JSON stderr so the user knows which file is bad. + const remedy = "dr artifact code doctor --fix" + + configPath := wapi.ConfigPath(dir) + + wrappedErr := fmt.Errorf("init aborted: project already linked (config unreadable at %s): %w", configPath, err) + + if outputFormat == outputformat.OutputFormatJSON { + renderAlreadyLinkedJSON(cmd.OutOrStdout(), nil, remedy) + + fmt.Fprintf(stderr, "Project is already linked but the config at %s is unreadable: %v\n", configPath, err) + fmt.Fprintln(stderr, "Run 'dr artifact code doctor --fix' to repair the config.") + + cmd.SilenceErrors = true + + return cli.ErrSilent + } + + printCorruptConfig(cmd.OutOrStdout(), dir) + + return wrappedErr + } + + // Fetch the linked artifact to determine health. + art, fetchErr := getArtifactFn(cfg.ArtifactID) + + gone := wldoctor.IsNotFound(fetchErr) + + mismatch := false + if fetchErr == nil && art != nil { + mismatch = wldoctor.IsCatalogMismatch(cfg.CatalogID, art) + } + + if gone || mismatch { + return handleGoneOrMismatch(cmd, dir, cfg, outputFormat, gone) + } + + // Healthy (or non-404 error — can't determine, treat as healthy). + const remedy = "dr artifact code doctor" + + if outputFormat == outputformat.OutputFormatJSON { + artifactID := cfg.ArtifactID + + renderAlreadyLinkedJSON(cmd.OutOrStdout(), &artifactID, remedy) + + printAlreadyLinkedHealthy(stderr, cfg.ArtifactID, dir) + + cmd.SilenceErrors = true + + return cli.ErrSilent } - printAlreadyLinked(cfg.ArtifactID, dir) + printAlreadyLinkedHealthy(cmd.OutOrStdout(), cfg.ArtifactID, dir) return errors.New("init aborted: project already linked") } diff --git a/cmd/artifact/code/init/cmd_test.go b/cmd/artifact/code/init/cmd_test.go index e4d85111e..3310a6bbe 100644 --- a/cmd/artifact/code/init/cmd_test.go +++ b/cmd/artifact/code/init/cmd_test.go @@ -15,16 +15,22 @@ package initcmd import ( + "bytes" + "context" "encoding/json" "errors" + "io" "os" "path/filepath" "strings" "testing" + "time" "github.com/datarobot/cli/internal/config/viperx" + core "github.com/datarobot/cli/internal/doctor" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/workload" + wldoctor "github.com/datarobot/cli/internal/workload/doctor" "github.com/datarobot/cli/internal/workload/wapi" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -40,6 +46,54 @@ func withFakeArtifact(t *testing.T, fn func(string) (*workload.Artifact, error)) t.Cleanup(func() { getArtifactFn = orig }) } +// withOfferRelink overrides the interactive relink offer seam. +func withOfferRelink(t *testing.T, fn func(io.Writer, string) (string, error)) { + t.Helper() + + orig := offerRelinkFn + offerRelinkFn = fn + + t.Cleanup(func() { offerRelinkFn = orig }) +} + +// withRelinkConfirm overrides the relink confirm builder seam. +func withRelinkConfirm(t *testing.T, fn func(*cobra.Command) wldoctor.RelinkConfirmFunc) { + t.Helper() + + orig := makeRelinkConfirmFn + makeRelinkConfirmFn = fn + + t.Cleanup(func() { makeRelinkConfirmFn = orig }) +} + +// withInteractive forces the interactive path (or non-interactive) by +// overriding the isInteractiveFn seam. +func withInteractive(t *testing.T, interactive bool) { + t.Helper() + + orig := isInteractiveFn + + if interactive { + isInteractiveFn = func(*cobra.Command) bool { return true } + } else { + isInteractiveFn = func(*cobra.Command) bool { return false } + } + + t.Cleanup(func() { isInteractiveFn = orig }) +} + +// withRunRelink overrides the relink execution seam. The override receives +// the context and options that runRelinkFromInit would pass to +// wldoctor.RunRelink, and returns the captured context via the closure. +func withRunRelink(t *testing.T, fn func(context.Context, wldoctor.RelinkOptions) ([]core.Action, error)) { + t.Helper() + + orig := runRelinkFn + runRelinkFn = fn + + t.Cleanup(func() { runRelinkFn = orig }) +} + // PreRunE is removed because unit tests don't go through auth. func newTestCmd(t *testing.T, dir string, yes bool, args []string) *cobra.Command { t.Helper() @@ -57,6 +111,21 @@ func newTestCmd(t *testing.T, dir string, yes bool, args []string) *cobra.Comman return cmd } +// runCapture executes cmd with captured stdout and stderr buffers wired via +// cmd.SetOut/SetErr so tests can inspect both streams independently. +func runCapture(t *testing.T, cmd *cobra.Command) (stdout, stderr string, err error) { + t.Helper() + + var stdoutBuf, stderrBuf bytes.Buffer + + cmd.SetOut(&stdoutBuf) + cmd.SetErr(&stderrBuf) + + err = cmd.Execute() + + return stdoutBuf.String(), stderrBuf.String(), err +} + func fakeArtifact(id, name, status string, codeRef *workload.DatarobotCodeRef) *workload.Artifact { art := &workload.Artifact{ID: id, Name: name, Status: status} @@ -189,10 +258,10 @@ func TestRunE_AlreadyLinked(t *testing.T) { ArtifactID: "art-existing-999", })) - withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { - t.Fatal("getArtifactFn must not be called when project is already linked") - - return nil, nil + // The linked artifact is healthy (exists, not locked, no catalog mismatch + // since config has no catalogId). + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "existing-art", "DRAFT", nil), nil }) cmd := newTestCmd(t, tmp, true, []string{"art-new-id"}) @@ -204,6 +273,9 @@ func TestRunE_AlreadyLinked(t *testing.T) { }) assert.Contains(t, out, "Already linked to artifact art-existing-999") + assert.Contains(t, out, "dr artifact code doctor") + assert.NotContains(t, out, "Delete") + assert.NotContains(t, out, "re-init") } func TestRunE_YesWithoutID(t *testing.T) { @@ -325,3 +397,697 @@ func TestCmd_DoesNotClobberGlobalYesViper(t *testing.T) { assert.False(t, viperx.GetBool("yes"), "init's --yes must not be bound to global viper key 'yes' (would clobber dotenv)") } + +// --------------------------------------------------------------------------- +// Already-linked branches +// --------------------------------------------------------------------------- + +// TestRunE_AlreadyLinked_GoneArtifact_NonInteractive verifies a gone artifact +// (404) in non-interactive mode prints guidance naming doctor --relink, +// never delete advice, state byte-identical. +func TestRunE_AlreadyLinked_GoneArtifact_NonInteractive(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ + ArtifactID: "art-gone-001", + })) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-gone-001"}) + + stdout, _, err := runCapture(t, cmd) + + require.Error(t, err) + assert.Contains(t, stdout, "art-gone-001") + assert.Contains(t, stdout, "dr artifact code doctor --relink ") + assert.NotContains(t, stdout, "Delete") + assert.NotContains(t, stdout, "rm -rf") + assert.NotContains(t, stdout, "re-init") + + // State byte-identical (no relink performed). + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + assert.Equal(t, "art-gone-001", cfg.ArtifactID) +} + +// TestRunE_AlreadyLinked_CatalogMismatch_NonInteractive verifies catalog +// mismatch in non-interactive mode points to doctor --relink, no delete +// advice, state unchanged. +func TestRunE_AlreadyLinked_CatalogMismatch_NonInteractive(t *testing.T) { + tmp := t.TempDir() + + catA := "cat-original-001" + catB := "cat-mismatch-002" + + require.NoError(t, os.MkdirAll(wapi.Dir(tmp), 0o755)) + + require.NoError(t, wapi.SaveConfig(tmp, wapi.Config{ + ArtifactID: "art-mismatch-001", + CatalogID: &catB, // hand-edited to a bogus value + CreatedAt: time.Now().UTC(), + CLIVersion: "test", + })) + require.NoError(t, wapi.SaveManifest(tmp, wapi.Manifest{ + Version: wapi.ManifestVersion, + Files: map[string]wapi.FileMeta{}, + })) + + // The artifact exists but its codeRef.CatalogID is catA (≠ config's catB). + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "mismatch-art", "DRAFT", &workload.DatarobotCodeRef{ + CatalogID: catA, + CatalogVersionID: "ver-001", + }), nil + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-mismatch-001"}) + + stdout, _, err := runCapture(t, cmd) + + require.Error(t, err) + assert.Contains(t, stdout, "catalog id no longer matches") + assert.Contains(t, stdout, "dr artifact code doctor --relink ") + assert.NotContains(t, stdout, "Delete") + assert.NotContains(t, stdout, "re-init") + + // State unchanged. + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + assert.Equal(t, "art-mismatch-001", cfg.ArtifactID) + require.NotNil(t, cfg.CatalogID) + assert.Equal(t, catB, *cfg.CatalogID) +} + +// TestRunE_AlreadyLinked_CorruptConfig verifies corrupt config (unreadable +// linked state) reports unreadable, remedy names doctor --fix, never +// deletion, no fetch, state byte-identical. +func TestRunE_AlreadyLinked_CorruptConfig(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, os.MkdirAll(wapi.Dir(tmp), 0o755)) + + // Write a corrupt config.json. + require.NoError(t, os.WriteFile(wapi.ConfigPath(tmp), []byte(`{"artifactId":"abc`), 0o600)) + + // getArtifactFn must NOT be called (config is unreadable, no fetch). + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + t.Fatal("getArtifactFn must not be called when config is corrupt") + + return nil, nil + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-some-id"}) + + stdout, _, err := runCapture(t, cmd) + + require.Error(t, err) + assert.Contains(t, stdout, "unreadable") + assert.Contains(t, stdout, "dr artifact code doctor --fix") + assert.NotContains(t, stdout, "Delete") + assert.NotContains(t, stdout, "rm -rf") + assert.NotContains(t, stdout, "re-init") + + // The error wraps the underlying LoadConfig error and includes the config path. + assert.Contains(t, err.Error(), "config unreadable") + assert.Contains(t, err.Error(), wapi.ConfigPath(tmp), "error must include the config path") + + // State byte-identical (config still corrupt). + _, statErr := os.Stat(wapi.ConfigPath(tmp)) + require.NoError(t, statErr) +} + +// TestRunE_AlreadyLinked_Healthy_JSON verifies a healthy linked artifact in +// JSON mode emits the pinned abort shape on stdout with human text on stderr, +// exit 1, no delete advice. +func TestRunE_AlreadyLinked_Healthy_JSON(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ + ArtifactID: "art-healthy-001", + })) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "healthy-art", "DRAFT", nil), nil + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-other-id"}) + require.NoError(t, cmd.Flags().Set("output-format", "json")) + + stdout, stderr, err := runCapture(t, cmd) + + require.Error(t, err) + + // stdout is pure JSON with the pinned shape. + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(stdout), &parsed)) + assert.Equal(t, "error", parsed["status"]) + assert.Equal(t, "already-linked", parsed["error"]) + assert.Equal(t, "art-healthy-001", parsed["artifactId"]) + assert.Contains(t, parsed["remedy"], "dr artifact code doctor") + + // stderr has human text, no delete advice. + assert.Contains(t, stderr, "Already linked to artifact art-healthy-001") + assert.NotContains(t, stderr, "Delete") + assert.NotContains(t, stderr, "re-init") +} + +// TestRunE_AlreadyLinked_Gone_JSON verifies a gone artifact in JSON mode emits +// the pinned shape with remedy containing doctor --relink, human text to +// stderr, no deletion, exit non-zero, state unchanged. +func TestRunE_AlreadyLinked_Gone_JSON(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ + ArtifactID: "art-gone-002", + })) + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-gone-002"}) + require.NoError(t, cmd.Flags().Set("output-format", "json")) + + stdout, stderr, err := runCapture(t, cmd) + + require.Error(t, err) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(stdout), &parsed)) + assert.Equal(t, "error", parsed["status"]) + assert.Equal(t, "already-linked", parsed["error"]) + assert.Equal(t, "art-gone-002", parsed["artifactId"]) + assert.Contains(t, parsed["remedy"], "doctor --relink") + + assert.NotContains(t, stderr, "Delete") + assert.NotContains(t, stderr, "re-init") + + // State unchanged. + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + assert.Equal(t, "art-gone-002", cfg.ArtifactID) +} + +// TestRunE_AlreadyLinked_CorruptConfig_JSON covers the corrupt-config branch +// in JSON mode: pinned shape with null artifactId, remedy doctor --fix. +func TestRunE_AlreadyLinked_CorruptConfig_JSON(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, os.MkdirAll(wapi.Dir(tmp), 0o755)) + require.NoError(t, os.WriteFile(wapi.ConfigPath(tmp), []byte(`{"artifactId":"abc`), 0o600)) + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + t.Fatal("getArtifactFn must not be called when config is corrupt") + + return nil, nil + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-some-id"}) + require.NoError(t, cmd.Flags().Set("output-format", "json")) + + stdout, stderr, err := runCapture(t, cmd) + + require.Error(t, err) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(stdout), &parsed)) + assert.Equal(t, "error", parsed["status"]) + assert.Equal(t, "already-linked", parsed["error"]) + assert.Nil(t, parsed["artifactId"]) + assert.Contains(t, parsed["remedy"], "doctor --fix") + + // JSON-mode stderr must include the config path (matching text mode). + assert.Contains(t, stderr, wapi.ConfigPath(tmp), "JSON stderr must include the config path") + assert.NotContains(t, stderr, "Delete") + assert.NotContains(t, stderr, "re-init") +} + +// TestRunE_AlreadyLinked_GoneArtifact_RelinkAccept verifies an interactive +// gone-artifact offer accepted drives the full relink (config repointed, +// manifest reset, relink history entry), working tree untouched. +func TestRunE_AlreadyLinked_GoneArtifact_RelinkAccept(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ + ArtifactID: "art-gone-003", + })) + + // The linked artifact is gone (404); the new artifact exists and is healthy. + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + if id == "art-gone-003" { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + } + + return fakeArtifact(id, "new-art", "DRAFT", nil), nil + }) + + // Force the interactive path. + withInteractive(t, true) + + // Simulate the user accepting the offer and entering a new artifact ID. + withOfferRelink(t, func(_ io.Writer, notice string) (string, error) { + assert.Contains(t, notice, "not found") + + return "art-new-003", nil + }) + + // Simulate the user confirming the relink. + withRelinkConfirm(t, func(_ *cobra.Command) wldoctor.RelinkConfirmFunc { + return func(_ string) bool { return true } + }) + + cmd := newTestCmd(t, tmp, false, []string{"art-gone-003"}) + + stdout, _, err := runCapture(t, cmd) + + require.NoError(t, err) + assert.Contains(t, stdout, "Relinked to artifact art-new-003") + + // Config repointed. + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + assert.Equal(t, "art-new-003", cfg.ArtifactID) + assert.Nil(t, cfg.LastSyncedVersionID) + + // Manifest reset to empty BASE. + manifest, manifestErr := wapi.LoadManifest(tmp) + require.NoError(t, manifestErr) + assert.Empty(t, manifest.Files) + assert.Nil(t, manifest.SyncedVersionID) + + // History has a relink entry. + historyData, historyErr := os.ReadFile(filepath.Join(wapi.Dir(tmp), wapi.HistoryFile)) + require.NoError(t, historyErr) + assert.Contains(t, string(historyData), `"op":"relink"`) + assert.Contains(t, string(historyData), `"from":"art-gone-003"`) + assert.Contains(t, string(historyData), `"to":"art-new-003"`) +} + +// TestRunE_AlreadyLinked_GoneArtifact_RelinkDecline verifies an interactive +// gone-artifact offer declined leaves state byte-identical. +func TestRunE_AlreadyLinked_GoneArtifact_RelinkDecline(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ + ArtifactID: "art-gone-004", + })) + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + }) + + withInteractive(t, true) + + // Simulate the user declining the offer. + withOfferRelink(t, func(_ io.Writer, _ string) (string, error) { + return "", nil // declined + }) + + cmd := newTestCmd(t, tmp, false, []string{"art-gone-004"}) + + _, _, err := runCapture(t, cmd) + + require.Error(t, err) + + // State byte-identical. + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + assert.Equal(t, "art-gone-004", cfg.ArtifactID) + + // No relink history entry. + historyData, historyErr := os.ReadFile(filepath.Join(wapi.Dir(tmp), wapi.HistoryFile)) + require.NoError(t, historyErr) + assert.NotContains(t, string(historyData), `"op":"relink"`) +} + +// TestRunE_AlreadyLinked_GoneArtifact_Relink404Target verifies an interactive +// offer accepted but the new artifact ID 404s → abort, state untouched. +func TestRunE_AlreadyLinked_GoneArtifact_Relink404Target(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ + ArtifactID: "art-gone-005", + })) + + // Both the old and new artifact IDs 404. + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + }) + + withInteractive(t, true) + + withOfferRelink(t, func(_ io.Writer, _ string) (string, error) { + return "art-also-gone-001", nil + }) + + withRelinkConfirm(t, func(_ *cobra.Command) wldoctor.RelinkConfirmFunc { + return func(_ string) bool { return true } + }) + + cmd := newTestCmd(t, tmp, false, []string{"art-gone-005"}) + + _, stderr, err := runCapture(t, cmd) + + require.Error(t, err) + assert.Contains(t, stderr, "not found") + + // State untouched. + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + assert.Equal(t, "art-gone-005", cfg.ArtifactID) + + historyData, historyErr := os.ReadFile(filepath.Join(wapi.Dir(tmp), wapi.HistoryFile)) + require.NoError(t, historyErr) + assert.NotContains(t, string(historyData), `"op":"relink"`) +} + +// TestRunE_AlreadyLinked_GoneArtifact_RelinkLockedTarget verifies an +// interactive offer accepted but the new artifact is locked → abort, state +// untouched. +func TestRunE_AlreadyLinked_GoneArtifact_RelinkLockedTarget(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ + ArtifactID: "art-gone-006", + })) + + // Old artifact 404; new artifact is locked. + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + if id == "art-gone-006" { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + } + + return fakeArtifact(id, "locked-target", "LOCKED", nil), nil + }) + + withInteractive(t, true) + + withOfferRelink(t, func(_ io.Writer, _ string) (string, error) { + return "art-locked-target-001", nil + }) + + withRelinkConfirm(t, func(_ *cobra.Command) wldoctor.RelinkConfirmFunc { + return func(_ string) bool { return true } + }) + + cmd := newTestCmd(t, tmp, false, []string{"art-gone-006"}) + + _, stderr, err := runCapture(t, cmd) + + require.Error(t, err) + assert.Contains(t, stderr, "locked") + + // State untouched. + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + assert.Equal(t, "art-gone-006", cfg.ArtifactID) +} + +// TestRunE_AlreadyLinked_CatalogMismatch_RelinkAccept verifies a catalog +// mismatch interactive offer accepted drives the full relink. +func TestRunE_AlreadyLinked_CatalogMismatch_RelinkAccept(t *testing.T) { + tmp := t.TempDir() + + catA := "cat-original-003" + catB := "cat-mismatch-003" + + require.NoError(t, os.MkdirAll(wapi.Dir(tmp), 0o755)) + + require.NoError(t, wapi.SaveConfig(tmp, wapi.Config{ + ArtifactID: "art-mismatch-003", + CatalogID: &catB, // mismatched + CreatedAt: time.Now().UTC(), + CLIVersion: "test", + })) + require.NoError(t, wapi.SaveManifest(tmp, wapi.Manifest{ + Version: wapi.ManifestVersion, + Files: map[string]wapi.FileMeta{}, + })) + + // The artifact exists but its codeRef.CatalogID is catA (≠ config's catB). + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + if id == "art-mismatch-003" { + return fakeArtifact(id, "mismatch-art", "DRAFT", &workload.DatarobotCodeRef{ + CatalogID: catA, + CatalogVersionID: "ver-001", + }), nil + } + + return fakeArtifact(id, "new-art", "DRAFT", nil), nil + }) + + withInteractive(t, true) + + withOfferRelink(t, func(_ io.Writer, notice string) (string, error) { + assert.Contains(t, notice, "mismatch") + + return "art-new-mismatch-001", nil + }) + + withRelinkConfirm(t, func(_ *cobra.Command) wldoctor.RelinkConfirmFunc { + return func(_ string) bool { return true } + }) + + cmd := newTestCmd(t, tmp, false, []string{"art-mismatch-003"}) + + stdout, _, err := runCapture(t, cmd) + + require.NoError(t, err) + assert.Contains(t, stdout, "Relinked to artifact art-new-mismatch-001") + + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + assert.Equal(t, "art-new-mismatch-001", cfg.ArtifactID) +} + +// TestRunE_AlreadyLinked_RelinkJSON verifies an interactive relink offer in +// JSON mode — stdout is pure JSON describing the relink result, +// prompts/warnings to stderr, exit 0. +func TestRunE_AlreadyLinked_RelinkJSON(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ + ArtifactID: "art-gone-007", + })) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + if id == "art-gone-007" { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + } + + return fakeArtifact(id, "new-art", "DRAFT", nil), nil + }) + + withInteractive(t, true) + + withOfferRelink(t, func(_ io.Writer, _ string) (string, error) { + return "art-new-007", nil + }) + + withRelinkConfirm(t, func(_ *cobra.Command) wldoctor.RelinkConfirmFunc { + return func(_ string) bool { return true } + }) + + cmd := newTestCmd(t, tmp, false, []string{"art-gone-007"}) + require.NoError(t, cmd.Flags().Set("output-format", "json")) + + stdout, _, err := runCapture(t, cmd) + + require.NoError(t, err) + + // stdout is pure JSON. + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(stdout), &parsed)) + assert.Equal(t, "ok", parsed["status"]) + assert.Equal(t, "art-new-007", parsed["artifactId"]) +} + +// TestRunE_AlreadyLinked_NoDeleteAdvice is the grep guard: no init output +// path advises deleting .datarobot/workload/ or .wapi state. +func TestRunE_AlreadyLinked_NoDeleteAdvice(t *testing.T) { + badSubstrings := []string{"Delete ", "rm -rf", "remove the state", "to re-init"} + + // Branch 1: healthy abort (text). + t.Run("healthy_text", func(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ArtifactID: "art-1"})) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "h", "DRAFT", nil), nil + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-x"}) + + stdout, stderr, _ := runCapture(t, cmd) + + for _, bad := range badSubstrings { + assert.NotContains(t, stdout, bad, "stdout must not contain %q", bad) + assert.NotContains(t, stderr, bad, "stderr must not contain %q", bad) + } + }) + + // Branch 2: gone guidance (text). + t.Run("gone_text", func(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ArtifactID: "art-2"})) + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-2"}) + + stdout, stderr, _ := runCapture(t, cmd) + + for _, bad := range badSubstrings { + assert.NotContains(t, stdout, bad, "stdout must not contain %q", bad) + assert.NotContains(t, stderr, bad, "stderr must not contain %q", bad) + } + }) + + // Branch 3: corrupt config (text). + t.Run("corrupt_text", func(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, os.MkdirAll(wapi.Dir(tmp), 0o755)) + require.NoError(t, os.WriteFile(wapi.ConfigPath(tmp), []byte(`{`), 0o600)) + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, nil + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-3"}) + + stdout, stderr, _ := runCapture(t, cmd) + + for _, bad := range badSubstrings { + assert.NotContains(t, stdout, bad, "stdout must not contain %q", bad) + assert.NotContains(t, stderr, bad, "stderr must not contain %q", bad) + } + }) + + // Branch 4: healthy abort (JSON). + t.Run("healthy_json", func(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ArtifactID: "art-4"})) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + return fakeArtifact(id, "h", "DRAFT", nil), nil + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-x"}) + require.NoError(t, cmd.Flags().Set("output-format", "json")) + + stdout, stderr, _ := runCapture(t, cmd) + + for _, bad := range badSubstrings { + assert.NotContains(t, stdout, bad, "stdout must not contain %q", bad) + assert.NotContains(t, stderr, bad, "stderr must not contain %q", bad) + } + }) + + // Branch 5: gone guidance (JSON). + t.Run("gone_json", func(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ArtifactID: "art-5"})) + + withFakeArtifact(t, func(_ string) (*workload.Artifact, error) { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + }) + + cmd := newTestCmd(t, tmp, true, []string{"art-5"}) + require.NoError(t, cmd.Flags().Set("output-format", "json")) + + stdout, stderr, _ := runCapture(t, cmd) + + for _, bad := range badSubstrings { + assert.NotContains(t, stdout, bad, "stdout must not contain %q", bad) + assert.NotContains(t, stderr, bad, "stderr must not contain %q", bad) + } + }) +} + +// TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext verifies the +// interactive relink-from-init path propagates the cobra command's context to +// RunRelink (previously passed context.Background()). The test overrides the +// runRelinkFn seam to capture the context received by the relink call and +// asserts it is the exact cmd.Context() value — not context.Background(). A +// sentinel value makes the check falsifiable: reverting to +// context.Background() drops the sentinel and the test fails. +func TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext(t *testing.T) { + tmp := t.TempDir() + + require.NoError(t, wapi.Initialize(tmp, wapi.InitOptions{ + ArtifactID: "art-ctx-001", + })) + + withFakeArtifact(t, func(id string) (*workload.Artifact, error) { + if id == "art-ctx-001" { + return nil, &drapi.HTTPError{StatusCode: 404, URL: "test"} + } + + return fakeArtifact(id, "new-art", "DRAFT", nil), nil + }) + + withInteractive(t, true) + + withOfferRelink(t, func(_ io.Writer, _ string) (string, error) { + return "art-ctx-002", nil + }) + + withRelinkConfirm(t, func(_ *cobra.Command) wldoctor.RelinkConfirmFunc { + return func(_ string) bool { return true } + }) + + // Use a context with a sentinel value so we can distinguish it from + // context.Background() and verify it is the exact cmd.Context(). + type ctxKey struct{} + + sentinel := &struct{}{} + + ctx := context.WithValue(context.Background(), ctxKey{}, sentinel) + + var capturedCtx context.Context + + withRunRelink(t, func(receivedCtx context.Context, opts wldoctor.RelinkOptions) ([]core.Action, error) { + capturedCtx = receivedCtx + + // Delegate to the real RunRelink so the relink actually executes. + return wldoctor.RunRelink(receivedCtx, opts) + }) + + cmd := newTestCmd(t, tmp, false, []string{"art-ctx-001"}) + + cmd.SetContext(ctx) + + _, _, err := runCapture(t, cmd) + + require.NoError(t, err) + + // The captured context must be the exact cmd.Context() — not + // context.Background(). If the code reverted to context.Background(), + // the sentinel value would be absent and this check would fail. + require.NotNil(t, capturedCtx, "runRelinkFn must have been called") + + assert.Equal(t, ctx, capturedCtx, + "the context passed to RunRelink must be cmd.Context(), not context.Background()") + + assert.Equal(t, sentinel, capturedCtx.Value(ctxKey{}), + "the sentinel value from cmd.Context() must propagate to RunRelink") + + // Verify the relink actually succeeded (proves the real RunRelink ran). + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + assert.Equal(t, "art-ctx-002", cfg.ArtifactID, "relink must have repointed to the new artifact") +} diff --git a/cmd/artifact/code/init/display.go b/cmd/artifact/code/init/display.go index 2c0b980f2..7462f0eaf 100644 --- a/cmd/artifact/code/init/display.go +++ b/cmd/artifact/code/init/display.go @@ -17,7 +17,9 @@ package initcmd import ( "encoding/json" "fmt" + "io" + core "github.com/datarobot/cli/internal/doctor" "github.com/datarobot/cli/internal/outputformat" "github.com/datarobot/cli/internal/workload" "github.com/datarobot/cli/internal/workload/wapi" @@ -33,6 +35,24 @@ type initResult struct { Dir string `json:"dir"` } +// alreadyLinkedJSON is the pinned JSON shape emitted on stdout when init +// aborts because the project is already linked. Human-readable text goes to +// stderr; stdout stays pure JSON. +type alreadyLinkedJSON struct { + Status string `json:"status"` + Error string `json:"error"` + ArtifactID *string `json:"artifactId"` + Remedy string `json:"remedy"` +} + +// relinkJSONResult describes a successful relink from the init offer in JSON +// mode. Stdout is pure JSON; all prompt/warning text went to stderr. +type relinkJSONResult struct { + Status string `json:"status"` + ArtifactID string `json:"artifactId"` + Actions []core.Action `json:"actions"` +} + func newInitResult(art workload.Artifact, dir string) initResult { r := initResult{ ArtifactID: art.ID, @@ -72,6 +92,38 @@ func renderInitResult(format outputformat.OutputFormat, result initResult) error return nil } +// renderAlreadyLinkedJSON emits the pinned abort shape on stdout. The caller +// is responsible for printing human-readable text to stderr and returning an +// error that drives the exit code. HTML escaping is disabled so remedy +// strings like "doctor --relink " survive verbatim (matching +// the doctor's JSON reporter). +func renderAlreadyLinkedJSON(w io.Writer, artifactID *string, remedy string) { + enc := json.NewEncoder(w) + + enc.SetEscapeHTML(false) + + _ = enc.Encode(alreadyLinkedJSON{ + Status: "error", + Error: "already-linked", + ArtifactID: artifactID, + Remedy: remedy, + }) +} + +// renderRelinkJSON emits the relink result as pure JSON on stdout. HTML +// escaping is disabled for consistency with the doctor's JSON reporter. +func renderRelinkJSON(w io.Writer, artifactID string, actions []core.Action) { + enc := json.NewEncoder(w) + + enc.SetEscapeHTML(false) + + _ = enc.Encode(relinkJSONResult{ + Status: "ok", + ArtifactID: artifactID, + Actions: actions, + }) +} + func printLinkedExistingCode(name, artifactID, verShort string) { fmt.Println(tui.SuccessStyle.Render( fmt.Sprintf("Linked to %s (%s) at version %s.", name, artifactID, verShort), @@ -86,13 +138,58 @@ func printLinkedEmptyArtifact(name, artifactID string) { fmt.Println(tui.DimStyle.Render("Run 'dr artifact code sync' to upload your files.")) } -func printAlreadyLinked(artifactID, dir string) { +// printAlreadyLinkedHealthy prints the already-linked abort message for a +// healthy linked artifact, pointing to the doctor for diagnosis. No delete +// advice. +func printAlreadyLinkedHealthy(w io.Writer, artifactID, dir string) { stateDir := wapi.Dir(dir) - fmt.Println(tui.ErrorStyle.Render( + fmt.Fprintln(w, tui.ErrorStyle.Render( fmt.Sprintf("Already linked to artifact %s; state exists at %s.", artifactID, stateDir), )) - fmt.Println(tui.DimStyle.Render(fmt.Sprintf("Delete %s to re-init.", stateDir))) + fmt.Fprintln(w, tui.DimStyle.Render("Run 'dr artifact code doctor' to diagnose the sync state.")) +} + +// printCorruptConfig prints the unreadable-config message, pointing to +// doctor --fix. No delete advice. +func printCorruptConfig(w io.Writer, dir string) { + configPath := wapi.ConfigPath(dir) + + fmt.Fprintln(w, tui.ErrorStyle.Render( + fmt.Sprintf("Project is already linked but the config at %s is unreadable.", configPath), + )) + fmt.Fprintln(w, tui.DimStyle.Render("Run 'dr artifact code doctor --fix' to repair the config.")) +} + +// printGoneGuidance prints the non-interactive guidance for a gone artifact, +// pointing to doctor --relink. No delete advice. +func printGoneGuidance(w io.Writer, artifactID string) { + fmt.Fprintln(w, tui.ErrorStyle.Render( + fmt.Sprintf("Already linked to artifact %s, but the artifact was not found (deleted?).", artifactID), + )) + fmt.Fprintln(w, tui.DimStyle.Render( + "Run 'dr artifact code doctor --relink ' to relink to a new artifact.", + )) +} + +// printMismatchGuidance prints the non-interactive guidance for a catalog +// mismatch, pointing to doctor --relink. No delete advice. +func printMismatchGuidance(w io.Writer, artifactID string) { + fmt.Fprintln(w, tui.ErrorStyle.Render( + fmt.Sprintf("Already linked to artifact %s, but the catalog id no longer matches.", artifactID), + )) + fmt.Fprintln(w, tui.DimStyle.Render( + "Run 'dr artifact code doctor --relink ' to relink to a new artifact.", + )) +} + +// printRelinkSuccess prints the text-mode success message after a relink +// from the init offer completes. +func printRelinkSuccess(w io.Writer, artifactID string) { + fmt.Fprintln(w, tui.SuccessStyle.Render( + fmt.Sprintf("Relinked to artifact %s; sync baseline reset.", artifactID), + )) + fmt.Fprintln(w, tui.DimStyle.Render("Run 'dr artifact code sync' to reconcile against the new artifact.")) } func shortVer(s string) string { diff --git a/cmd/artifact/code/init/display_test.go b/cmd/artifact/code/init/display_test.go index 6c4291013..7ff040d1b 100644 --- a/cmd/artifact/code/init/display_test.go +++ b/cmd/artifact/code/init/display_test.go @@ -65,13 +65,121 @@ func TestPrintLinkedEmptyArtifact_IncludesArtifactName(t *testing.T) { assert.Contains(t, out, "Run 'dr artifact code sync' to upload your files.") } -func TestPrintAlreadyLinked_IncludesPath(t *testing.T) { - out := captureStdout(t, func() { - printAlreadyLinked("art-abc-123", "/tmp/proj") - }) +func TestPrintAlreadyLinkedHealthy_NoDeleteAdvice(t *testing.T) { + var buf bytes.Buffer + + printAlreadyLinkedHealthy(&buf, "art-abc-123", "/tmp/proj") + + out := buf.String() + + assert.Contains(t, out, "Already linked to artifact art-abc-123") + assert.Contains(t, out, wapi.Dir("/tmp/proj")) + assert.Contains(t, out, "dr artifact code doctor") + assert.NotContains(t, out, "Delete") + assert.NotContains(t, out, "rm -rf") + assert.NotContains(t, out, "re-init") +} + +func TestPrintCorruptConfig_NoDeleteAdvice(t *testing.T) { + var buf bytes.Buffer + + printCorruptConfig(&buf, "/tmp/proj") - assert.Contains(t, out, "Already linked to artifact art-abc-123; state exists at "+wapi.Dir("/tmp/proj")+".") - assert.Contains(t, out, "Delete "+wapi.Dir("/tmp/proj")+" to re-init.") + out := buf.String() + + assert.Contains(t, out, "unreadable") + assert.Contains(t, out, wapi.ConfigPath("/tmp/proj")) + assert.Contains(t, out, "dr artifact code doctor --fix") + assert.NotContains(t, out, "Delete") + assert.NotContains(t, out, "rm -rf") + assert.NotContains(t, out, "re-init") +} + +func TestPrintGoneGuidance_NoDeleteAdvice(t *testing.T) { + var buf bytes.Buffer + + printGoneGuidance(&buf, "art-gone-001") + + out := buf.String() + + assert.Contains(t, out, "art-gone-001") + assert.Contains(t, out, "not found") + assert.Contains(t, out, "dr artifact code doctor --relink ") + assert.NotContains(t, out, "Delete") + assert.NotContains(t, out, "rm -rf") + assert.NotContains(t, out, "re-init") +} + +func TestPrintMismatchGuidance_NoDeleteAdvice(t *testing.T) { + var buf bytes.Buffer + + printMismatchGuidance(&buf, "art-mismatch-001") + + out := buf.String() + + assert.Contains(t, out, "art-mismatch-001") + assert.Contains(t, out, "catalog id no longer matches") + assert.Contains(t, out, "dr artifact code doctor --relink ") + assert.NotContains(t, out, "Delete") + assert.NotContains(t, out, "rm -rf") + assert.NotContains(t, out, "re-init") +} + +func TestPrintRelinkSuccess_NoDeleteAdvice(t *testing.T) { + var buf bytes.Buffer + + printRelinkSuccess(&buf, "art-new-001") + + out := buf.String() + + assert.Contains(t, out, "Relinked to artifact art-new-001") + assert.Contains(t, out, "sync baseline reset") + assert.Contains(t, out, "dr artifact code sync") + assert.NotContains(t, out, "Delete") + assert.NotContains(t, out, "rm -rf") +} + +func TestRenderAlreadyLinkedJSON_PinnedShape(t *testing.T) { + var buf bytes.Buffer + + artifactID := "art-abc-123" + + renderAlreadyLinkedJSON(&buf, &artifactID, "dr artifact code doctor") + + var parsed map[string]any + + require.NoError(t, json.Unmarshal(buf.Bytes(), &parsed)) + + assert.Equal(t, "error", parsed["status"]) + assert.Equal(t, "already-linked", parsed["error"]) + assert.Equal(t, "art-abc-123", parsed["artifactId"]) + assert.Equal(t, "dr artifact code doctor", parsed["remedy"]) +} + +func TestRenderAlreadyLinkedJSON_NullArtifactID(t *testing.T) { + var buf bytes.Buffer + + renderAlreadyLinkedJSON(&buf, nil, "dr artifact code doctor --fix") + + var parsed map[string]any + + require.NoError(t, json.Unmarshal(buf.Bytes(), &parsed)) + + assert.Nil(t, parsed["artifactId"]) + assert.Equal(t, "dr artifact code doctor --fix", parsed["remedy"]) +} + +func TestRenderRelinkJSON_PureJSON(t *testing.T) { + var buf bytes.Buffer + + renderRelinkJSON(&buf, "art-new-001", nil) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal(buf.Bytes(), &parsed)) + + assert.Equal(t, "ok", parsed["status"]) + assert.Equal(t, "art-new-001", parsed["artifactId"]) } func TestRenderInitResult_TextWithCodeRef(t *testing.T) { @@ -138,3 +246,25 @@ func TestShortVer(t *testing.T) { assert.Equal(t, tc.want, shortVer(tc.in), "input=%q", tc.in) } } + +// TestNoDeleteAdviceAnywhere is the grep guard: no display function +// produces "Delete", "rm -rf", "remove the state", or "to re-init" advice. +func TestNoDeleteAdviceAnywhere(t *testing.T) { + dirs := []string{"/tmp/proj", "/tmp/another"} + + for _, dir := range dirs { + var buf bytes.Buffer + + printAlreadyLinkedHealthy(&buf, "art-1", dir) + printCorruptConfig(&buf, dir) + printGoneGuidance(&buf, "art-1") + printMismatchGuidance(&buf, "art-1") + printRelinkSuccess(&buf, "art-new") + + out := buf.String() + + for _, bad := range []string{"Delete ", "rm -rf", "remove the state", "to re-init"} { + assert.NotContains(t, out, bad, "display output must not contain %q: %s", bad, out) + } + } +} diff --git a/cmd/artifact/code/init/relink.go b/cmd/artifact/code/init/relink.go new file mode 100644 index 000000000..68f1e5798 --- /dev/null +++ b/cmd/artifact/code/init/relink.go @@ -0,0 +1,248 @@ +// 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 initcmd + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/datarobot/cli/cmd/artifact/code/internal/dirprompt" + "github.com/datarobot/cli/internal/cli" + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/misc/reader" + "github.com/datarobot/cli/internal/outputformat" + wldoctor "github.com/datarobot/cli/internal/workload/doctor" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/spf13/cobra" +) + +// offerRelinkFn is the interactive relink offer. It prints the notice to w, +// asks whether the user wants to relink (default No), and if yes, prompts for +// the new artifact ID. Returns the new ID and nil if accepted; "" and nil if +// declined; "" and an error on read failure (Ctrl-C, EOF). Tests override this +// to simulate user input without a real terminal. +var offerRelinkFn = defaultOfferRelink + +// makeRelinkConfirmFn builds the confirm function for RunRelink from the init +// offer. Tests override this to inject a fake confirm. The production +// implementation mirrors the doctor command's makeRelinkConfirm: interactive +// TTY shows a [y/N] prompt (empty Enter declines); non-interactive prints the +// warning and proceeds. +var makeRelinkConfirmFn = makeInitRelinkConfirm + +// isInteractiveFn reports whether the init command should use interactive +// prompts for the relink offer. Production: not non-interactive AND stdin is +// a terminal. Tests override this to force the interactive path without a +// real terminal. +var isInteractiveFn = defaultIsInteractive + +// runRelinkFn is the relink execution seam. Production delegates to +// wldoctor.RunRelink; tests override this to capture the propagated context +// and assert it equals cmd.Context() (not context.Background()). +var runRelinkFn = wldoctor.RunRelink + +// defaultIsInteractive returns true when the command is interactive (not +// --yes and stdin is a TTY). +func defaultIsInteractive(cmd *cobra.Command) bool { + return !cli.IsNonInteractive(cmd) && reader.IsStdinTerminal() +} + +// handleGoneOrMismatch handles the gone-artifact (404) or catalog-mismatch +// branch of the already-linked check: interactive → offer to relink in place +// (prompt for new artifact id, then run the doctor --relink path incl. +// warn/confirm and safety gates); non-interactive → print guidance naming +// dr artifact code doctor --relink . No message advises deleting +// state. +func handleGoneOrMismatch(cmd *cobra.Command, dir string, cfg wapi.Config, outputFormat outputformat.OutputFormat, gone bool) error { + stderr := cmd.ErrOrStderr() + + remedy := wldoctor.RemedyRelink + + // Non-interactive (--yes or non-TTY): print guidance, abort. + if !isInteractiveFn(cmd) { + return reportGoneOrMismatchAbort(cmd, cfg.ArtifactID, outputFormat, gone, remedy) + } + + // Interactive: offer to relink in place. + var notice string + + if gone { + notice = fmt.Sprintf("Linked artifact %s was not found (deleted?).", cfg.ArtifactID) + } else { + notice = fmt.Sprintf("Linked artifact %s has a catalog id mismatch.", cfg.ArtifactID) + } + + newID, offerErr := offerRelinkFn(stderr, notice) + if offerErr != nil || newID == "" { + // Declined or read error → abort with guidance. + return reportGoneOrMismatchAbort(cmd, cfg.ArtifactID, outputFormat, gone, remedy) + } + + // Accepted: run the relink. + return runRelinkFromInit(cmd, dir, cfg.ArtifactID, newID, outputFormat) +} + +// reportGoneOrMismatchAbort prints the non-interactive guidance (or the JSON +// abort shape) and returns an error to drive exit 1. +func reportGoneOrMismatchAbort(cmd *cobra.Command, artifactID string, outputFormat outputformat.OutputFormat, gone bool, remedy string) error { + stderr := cmd.ErrOrStderr() + + if outputFormat == outputformat.OutputFormatJSON { + id := artifactID + + renderAlreadyLinkedJSON(cmd.OutOrStdout(), &id, remedy) + + if gone { + printGoneGuidance(stderr, artifactID) + } else { + printMismatchGuidance(stderr, artifactID) + } + + cmd.SilenceErrors = true + + return cli.ErrSilent + } + + if gone { + printGoneGuidance(cmd.OutOrStdout(), artifactID) + } else { + printMismatchGuidance(cmd.OutOrStdout(), artifactID) + } + + return errors.New("init aborted: project already linked") +} + +// runRelinkFromInit executes the relink operation from the init offer and +// renders the result. On success, stdout carries the relink result (text or +// JSON) and the command returns nil (exit 0). On abort, the error drives +// exit 1. +func runRelinkFromInit(cmd *cobra.Command, dir, oldID, newID string, outputFormat outputformat.OutputFormat) error { + stderr := cmd.ErrOrStderr() + + actions, err := runRelinkFn(cmd.Context(), wldoctor.RelinkOptions{ + ProjectDir: dir, + NewArtifactID: newID, + Store: wldoctor.ArtifactGetterFunc(getArtifactFn), + Confirm: makeRelinkConfirmFn(cmd), + }) + if err != nil { + // Relink aborted (404, locked, wrong type, lock held, declined, + // API unreachable). State is byte-identical. + if outputFormat == outputformat.OutputFormatJSON { + id := oldID + + renderAlreadyLinkedJSON(cmd.OutOrStdout(), &id, wldoctor.RemedyRelink) + + printRelinkAbortReason(stderr, err, actions) + + cmd.SilenceErrors = true + + return cli.ErrSilent + } + + printRelinkAbortReason(stderr, err, actions) + + return errors.New("init aborted: relink failed") + } + + // Relink succeeded: render the result. + if outputFormat == outputformat.OutputFormatJSON { + renderRelinkJSON(cmd.OutOrStdout(), newID, actions) + + return nil + } + + printRelinkSuccess(cmd.OutOrStdout(), newID) + + return nil +} + +// printRelinkAbortReason prints the relink abort reason to stderr. For +// ErrRelinkAbort the actions array describes the reason; for other errors +// (e.g. ErrRelinkAPIUnreachable) the error itself is the message. +func printRelinkAbortReason(stderr io.Writer, err error, actions []core.Action) { + if !errors.Is(err, wldoctor.ErrRelinkAbort) { + fmt.Fprintln(stderr, err) + + return + } + + if len(actions) > 0 { + fmt.Fprintln(stderr, actions[0].Reason) + } +} + +// defaultOfferRelink is the production interactive relink offer. It prints +// the notice to w, asks whether the user wants to relink (default No), and if +// yes, prompts for the new artifact ID. +// +// An empty entry at the new-artifact-ID prompt (dirprompt.Ask returning "") is +// treated as a decline, not a re-prompt. This is the intended default-No UX: +// dirprompt.Ask's contract returns "" on empty Enter, and the caller +// (handleGoneOrMismatch) treats "" as "declined, abort with guidance" — +// consistent with the bespoke [y/N] confirm prompt where empty Enter also +// declines. Re-prompting would surprise users who pressed Enter expecting to +// cancel. +func defaultOfferRelink(w io.Writer, notice string) (string, error) { + fmt.Fprintln(w, notice) + + fmt.Fprint(w, "Relink to a new artifact? [y/N] ") + + line, err := reader.ReadString() + if err != nil { + return "", err + } + + answer := strings.TrimSpace(strings.ToLower(line)) + if answer != "y" && answer != "yes" { + return "", nil // declined + } + + return dirprompt.Ask("New artifact ID") +} + +// makeInitRelinkConfirm builds the confirm function for the relink from the +// init offer. Interactive (TTY, no --yes): the warning and a [y/N] prompt go +// to stderr; only "y"/"yes" proceeds (empty Enter declines — this is the +// bespoke default-No prompt, NOT reader.AskYesNo). Non-interactive (--yes or +// non-TTY): the warning is printed to stderr and the relink proceeds. +func makeInitRelinkConfirm(cmd *cobra.Command) wldoctor.RelinkConfirmFunc { + nonInteractive := cli.IsNonInteractive(cmd) + + stderr := cmd.ErrOrStderr() + + return func(warning string) bool { + if nonInteractive || !reader.IsStdinTerminal() { + fmt.Fprintln(stderr, warning) + + return true + } + + fmt.Fprintln(stderr, warning) + + fmt.Fprint(stderr, "Proceed? [y/N] ") + + line, err := reader.ReadString() + if err != nil { + return false + } + + answer := strings.TrimSpace(strings.ToLower(line)) + + return answer == "y" || answer == "yes" + } +} diff --git a/docs/commands/README.md b/docs/commands/README.md index bb719670e..72b751f7e 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -149,7 +149,8 @@ dr │ ├── init Link a directory to an artifact │ ├── sync Push and pull code changes │ ├── versions List catalog versions -│ └── checkout Download a version snapshot +│ ├── checkout Download a version snapshot +│ └── doctor Diagnose and repair the local sync state ├── workload Workload management (alias: wl, feature-gated) │ ├── create Create (deploy) a workload │ ├── get Display details of a workload @@ -374,7 +375,7 @@ For detailed documentation on each command, see: - **[artifact](artifact.md)**—build and manage the container artifacts that back workloads (feature-gated behind `DATAROBOT_CLI_FEATURE_WORKLOAD=true`). - `create` / `get` / `list` / `lock` / `delete`—the draft-to-locked artifact lifecycle. - `build`—`create` / `get` / `list` / `logs` for container image builds. - - `code`—`init` / `sync` / `versions` / `checkout` to sync local code with an artifact via a `.datarobot/workload/` state directory. + - `code`—`init` / `sync` / `versions` / `checkout` to sync local code with an artifact via a `.datarobot/workload/` state directory, plus `doctor` to diagnose and repair that state. - **[workload](workload.md)**—deploy and operate workloads created from artifacts (alias `wl`; feature-gated behind `DATAROBOT_CLI_FEATURE_WORKLOAD=true`). - `create` / `get` / `list` / `delete`—the workload lifecycle. diff --git a/docs/commands/artifact.md b/docs/commands/artifact.md index 396e9d500..f775802f5 100644 --- a/docs/commands/artifact.md +++ b/docs/commands/artifact.md @@ -57,7 +57,7 @@ dr artifact lock | `dr artifact lock` | `PATCH /api/v2/artifacts/{id}/` | Promote a draft to locked (immutable). | | `dr artifact delete` | `DELETE /api/v2/artifacts/{id}/` | Delete an artifact. | | `dr artifact build …` | `…/artifacts/{id}/builds[/{build-id}]` | Trigger, inspect, and read logs from image builds. | -| `dr artifact code …` | DataRobot catalog (Files API) | Sync local code with an artifact (`init`, `sync`, `versions`, `checkout`). | +| `dr artifact code …` | DataRobot catalog (Files API) | Sync local code with an artifact (`init`, `sync`, `versions`, `checkout`, `doctor`). | ## Subcommands @@ -171,6 +171,7 @@ dr artifact code init [] [--dir ] [--yes] dr artifact code sync [--dir ] [--dry-run | --diff] [--yes] dr artifact code versions [--dir ] [--limit N] dr artifact code checkout [] [--dir ] [--clean] +dr artifact code doctor [--dir ] [--output-format text|json] [--fix | --relink ] ``` - `init` creates the `.datarobot/workload/` state directory and binds it to an existing draft artifact. The artifact must already exist (`dr artifact create` or the DataRobot UI); these commands manage an artifact's code, not its lifecycle. It also drops a starter `.drignore` at the project root, in gitignore syntax, listing what `sync` should leave out. Edit it and commit it. A project that already has an ignore file under either name keeps it, and no new one is written. @@ -179,6 +180,7 @@ dr artifact code checkout [] [--dir ] [--clean] - For Python projects, the image build requires a `uv.lock` next to `pyproject.toml`. When your project has `pyproject.toml` but no `uv.lock`, `sync` generates one automatically by running your local `uv lock` (your uv configuration, private indexes, and credentials apply) and uploads it with the rest of your code — commit the generated file to your repo. If `uv` is not installed or lock generation fails, sync still completes and prints what to do (`uv lock`, then re-sync); the image build will fail until a lock file is added. This also happens on `--dry-run`/`--diff`, so the preview matches what a real sync would upload. An existing `uv.lock` is never modified, and sync warns if your `.drignore` excludes it. - `versions` lists the artifact's catalog versions, marking the one the artifact currently points at (`*`) and noting the one you last synced. - `checkout` downloads a version into `.datarobot/workload/.checkouts//` for read-only inspection; your working directory is left untouched. `--clean` removes checkout directories instead of downloading. +- `doctor` is a read-only diagnostic of a linked project's sync state. It runs local checks (linked artifact, `config.json`/`manifest.json` health, config/manifest agreement, interrupted rollbacks, the sync lock) and, when credentials resolve, remote checks against the linked artifact, reporting each as `OK`, `WARN`, `FAIL`, or `SKIP` with a concrete remedy. Pass `--fix` to run the safe local auto-repairs (rebuild the manifest from config, restore an interrupted rollback, clear a stale lock) and re-run the suite so the report and exit code reflect the post-fix state; pass `--relink ` to repoint the project at a different artifact with a fresh sync baseline. The two flags are mutually exclusive, and a live process holding the sync lock gates all repairs. Exit code is `1` when any check `FAIL`s. See the [architecture and check-authoring guide](../development/doctor.md) for contributor details. ## Shared flags diff --git a/docs/development/doctor.md b/docs/development/doctor.md new file mode 100644 index 000000000..930948db0 --- /dev/null +++ b/docs/development/doctor.md @@ -0,0 +1,157 @@ +# `dr artifact code doctor` — Architecture & Check-Authoring Guide + +Audience: CLI contributors + +## Overview + +`dr artifact code doctor` is a read-only diagnostic for a project's +`.datarobot/workload/` sync state. It inspects the local state — linked +artifact, `config.json`/`manifest.json` health, config/manifest agreement, +interrupted rollbacks, and the sync lock. If credentials resolve, it also +checks the linked artifact's remote health. + +At the end it reports each check as `OK`, `WARN`, `FAIL`, or `SKIP` with a +concrete remedy for any issues. + +Key invariants: + +- **Read-only diagnosis.** Checks perform zero local writes and zero server + writes. Only `--fix` and `--relink` write, and only to local state. +- **Exit-code contract.** `0` when no check `FAIL`s (`OK`/`WARN`/`SKIP` + allowed); `1` when any check `FAIL`s; `1` on usage errors. +- **JSON purity.** With `--output-format json`, stdout is pure JSON; all + warnings, prompts, and logs go to stderr. +- **Soft auth model.** Auth is probed non-fatally inside `RunE` (no + `EnsureAuthenticatedE`, no login wizard, no `drconfig.yaml` write). Local + checks always run; remote checks report `SKIP` with a connectivity/`dr auth + login` remedy when the API is unreachable or unauthenticated. `--relink` + is the exception — it must fetch the target artifact, so it hard-errors + when the API is out of reach. + +`--fix` runs safe local auto-repairs (rebuild the manifest from config, +restore an interrupted rollback, clear a stale lock) behind a global safety +gate that skips every repair while a live process holds the sync lock, then +re-runs the full check suite so the report and exit code reflect the post-fix +state. `--relink ` repoints the project at a different +artifact with a fresh sync baseline (empty BASE reset). `--fix` and `--relink` +are mutually exclusive. + +## Architecture + +The feature is layered in three packages. The command layer wires Cobra +flags and the soft auth probe; the workload layer owns the wapi-specific +checks and repairs; the generic framework layer owns the ordered runner, the +reporters, and exit-code aggregation. The generic layer imports nothing +about workload state, so a future top-level `dr doctor` can reuse it. + + + +```mermaid +flowchart TD + subgraph CMD["cmd/artifact/code/doctor (cobra wiring)"] + F["flags: --dir, --output-format, --yes, --fix, --relink"] + P["soft auth probe (non-fatal, no login wizard)"] + RUN["pageDoctor"] + end + + subgraph WL["internal/workload/doctor (wapi checks + repairs)"] + L["6 local checks: presence, config, manifest, divergence, rollback, lock"] + R["4 remote checks: artifact-exists, artifact-locked, catalog-mismatch, drift"] + S["one GetArtifact snapshot (ArtifactGetter seam)"] + end + + subgraph CORE["internal/doctor (generic framework)"] + RN["Runner (ordered execution)"] + REP["Report + exit-code: 1 if any FAIL"] + T["text reporter (lipgloss table)"] + J["JSON reporter (pure stdout)"] + end + + API["DataRobot API (read-only GET)"] + + F --> P + P --> RUN + RUN --> L + RUN --> R + R --> S + S --> API + L --> RN + R --> RN + RN --> REP + REP --> T + REP --> J + + P -.->|"offline: remote checks SKIP"| R + L -.->|"presence FAIL: skip all"| SK1["remaining checks SKIP"] + L -.->|"config FAIL: skip divergence + remote"| SK2["divergence + remote SKIP"] + + subgraph REPAIR["repair phase (side branch)"] + G["global held-lock safety gate"] + FIX["--fix: manifest, rollback, lock"] + REL["--relink: repoint + fresh BASE"] + end + + RUN -.-> G + G -.->|"live holder: skip all repairs"| SK3["all repairs skipped"] + G -.-> FIX + G -.-> REL + REL -.->|"hard-requires API"| API +``` + +The four remote checks share exactly one `GetArtifact` per run through the +injected `ArtifactGetter` seam (`remoteSnapshot` memoizes the fetch with +`sync.Once`), so a mid-run disappearance collapses to a single read. SKIP +cascades are honest per-run observations, not construction-time snapshots: +each check re-reads local state at `Run` time, so `wapi.presence` failing +makes every later check report `SKIP` ("no linked state"), and `wapi.config` +failing makes the divergence and all remote checks `SKIP` (no artifact id). +A `404` is owned solely by `remote.artifact-exists` (the only check allowed +to `FAIL` on one); the dependent remote checks `SKIP` rather than piling on. +Any non-`404` remote failure maps to `SKIP` with the connectivity remedy — +never a misleading `OK` and never `FAIL`-as-deleted. + +## Adding your own check + +The check suite is an ordered list of `doctor.Check` implementations built by +`Checks`/`LocalChecks`/`RemoteChecks` in `internal/workload/doctor`. A new +check is five small steps; both reporters pick it up automatically because +they render whatever the `Runner` returns. + +```mermaid +flowchart TD + A["1. implement doctor.Check: ID, Name, Run(ctx) -> Result"] + B["2. add a canonical remedy constant in remedies.go"] + C["3. register the constructor in the check-list composition (LocalChecks/RemoteChecks)"] + D["4. place it at a deliberate position — order is pinned and user-visible"] + E["5. write tests with the fake seams: initProject temp state, ArtifactGetterFunc"] + F["both reporters render it automatically"] + + A --> B + B --> C + C --> D + D --> E + E --> F +``` + +Notes for check authors: + +- **`Result` shape:** `{CheckID, Status, Summary, Remedy, Details, Fixable}`. + `CheckID` is stamped by the `Runner` from `Check.ID()`, so do not set it + yourself. `Details` is an optional `map[string]string` surfaced in JSON + (e.g. `{"path": "/abs/file"}` for corrupt-file checks); omit it when nil. +- **Remedies are canonical.** Add exactly one remedy string per check + condition in `remedies.go` and reuse it verbatim in both reporters — the + command layer renders remedy strings as-is, never rewording them. +- **Check order is pinned and user-visible.** The six local checks run + before the four remote checks, in the table order. New checks append at a + deliberate position (the M4 extras append after the ticket-scope ten). Do + not reorder existing checks without intent: IDs and order are part of the + output contract. +- **Pure diagnostics.** A check `Run` must not mutate local state or make + server writes. Repairs live behind `--fix`/`--relink` in the command layer. +- **Reuse the SKIP cascades.** Call `skipIfUnlinked` (and the + `linkedConfig`/`fetchedArtifact` helpers for remote checks) so a missing + precondition reports an honest `SKIP` instead of a misleading `FAIL`. +- **Test seams.** Use the existing `initProject`-style temp state and the + `ArtifactGetterFunc` fake (no network). Inject a `GOOS` seam for any + platform-specific behavior so it is unit-testable on any host. diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go new file mode 100644 index 000000000..462b784a8 --- /dev/null +++ b/internal/doctor/doctor.go @@ -0,0 +1,106 @@ +// 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 doctor is a generic, state-agnostic check-and-report framework: a +// Check interface, a Result type, an ordered Runner, and text/JSON reporters. +// Concrete checks (e.g. workload sync-state checks) live in their own packages +// and plug into the Runner, keeping this layer reusable for a future +// top-level "dr doctor". +package doctor + +import "context" + +// Status is the outcome of a single check. Check-level statuses render +// uppercase; the run's overall verdict (derived from these) renders lowercase. +type Status string + +const ( + // StatusOK means the condition checked for is healthy. + StatusOK Status = "OK" + + // StatusWARN means something needs attention but the run can proceed. + StatusWARN Status = "WARN" + + // StatusFAIL means the condition is broken; any FAIL makes the exit code 1. + StatusFAIL Status = "FAIL" + + // StatusSKIP means the check could not meaningfully run (e.g. an earlier + // check failed and this one depends on it). SKIP is honest reporting, + // never a silent pass. + StatusSKIP Status = "SKIP" +) + +// Result is the outcome of one check. CheckID is normally stamped by the +// Runner from the Check's ID, so checks do not need to set it. +type Result struct { + // CheckID is the stable namespaced identifier of the check (e.g. + // "wapi.config"); matches Check.ID. + CheckID string + + // Status is the check outcome. + Status Status + + // Summary is a one-line human-readable description of the finding. + Summary string + + // Remedy is the canonical remedy string for a non-OK result; empty for OK. + Remedy string + + // Details holds optional structured extras (e.g. {"path": "/abs/file"}) + // surfaced in JSON output; omitted when nil. + Details map[string]string + + // Fixable reports whether "doctor --fix" can repair this condition. + Fixable bool +} + +// Check is a single diagnostic. Implementations are pure diagnostics: they +// MUST NOT mutate local state or perform server writes; repairs live behind +// explicit repair operations in the owning command layer. +type Check interface { + // ID returns the stable namespaced identifier (e.g. "wapi.presence"). + ID() string + + // Name returns a human-readable name for display. + Name() string + + // Run executes the check and returns its Result. + Run(ctx context.Context) Result +} + +// ActionStatus is the outcome of one repair operation in a repair run (--fix / --relink). +type ActionStatus string + +const ( + // ActionPerformed means the repair was executed successfully. + ActionPerformed ActionStatus = "performed" + + // ActionSkipped means the repair was not executed; Reason says why. + ActionSkipped ActionStatus = "skipped" + + // ActionNotNeeded means the repair had nothing to do (already healthy). + ActionNotNeeded ActionStatus = "not-needed" +) + +// Action describes one repair operation for the reporters' actions section. +type Action struct { + // ID is the check or operation identifier this action belongs to. + ID string `json:"id"` + + // Status is performed, skipped, or not-needed. + Status ActionStatus `json:"status"` + + // Reason explains a skip (or other non-obvious outcome); omitted when empty. + Reason string `json:"reason,omitempty"` +} diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go new file mode 100644 index 000000000..1b8ac1aa5 --- /dev/null +++ b/internal/doctor/doctor_test.go @@ -0,0 +1,187 @@ +// 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 doctor + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubCheck is a Check with canned output, used across runner and report tests. +type stubCheck struct { + id, name string + + res Result +} + +func (s stubCheck) ID() string { return s.id } +func (s stubCheck) Name() string { return s.name } + +func (s stubCheck) Run(_ context.Context) Result { return s.res } + +func TestRunner_PreservesCheckOrder(t *testing.T) { + checks := []Check{ + stubCheck{id: "b.check", name: "B", res: Result{Status: StatusOK, Summary: "b"}}, + stubCheck{id: "a.check", name: "A", res: Result{Status: StatusOK, Summary: "a"}}, + stubCheck{id: "c.check", name: "C", res: Result{Status: StatusFAIL, Summary: "c"}}, + } + + results := NewRunner(checks...).Run(context.Background()) + + require.Len(t, results, 3) + + got := make([]string, 0, len(results)) + + for _, res := range results { + got = append(got, res.CheckID) + } + + assert.Equal(t, []string{"b.check", "a.check", "c.check"}, got) +} + +func TestRunner_SetsCheckIDFromCheck(t *testing.T) { + // A check that returns a Result without a CheckID still gets one stamped + // by the runner, so reporters never render an anonymous row. + c := stubCheck{id: "x.y", name: "X", res: Result{Status: StatusOK, Summary: "fine"}} + + results := NewRunner(c).Run(context.Background()) + + require.Len(t, results, 1) + + assert.Equal(t, "x.y", results[0].CheckID) +} + +func TestRunner_EmptyChecks(t *testing.T) { + results := NewRunner().Run(context.Background()) + + assert.Empty(t, results) +} + +func TestReport_ExitCode(t *testing.T) { + cases := []struct { + name string + checks []Result + want int + }{ + {"no checks", nil, 0}, + {"all ok", []Result{{CheckID: "a", Status: StatusOK}}, 0}, + {"warn only", []Result{{CheckID: "a", Status: StatusWARN}}, 0}, + {"skip only", []Result{{CheckID: "a", Status: StatusSKIP}}, 0}, + {"mixed without fail", []Result{ + {CheckID: "a", Status: StatusOK}, + {CheckID: "b", Status: StatusWARN}, + {CheckID: "c", Status: StatusSKIP}, + }, 0}, + {"fail present", []Result{ + {CheckID: "a", Status: StatusOK}, + {CheckID: "b", Status: StatusFAIL}, + }, 1}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + report := NewReport("/tmp/x", nil, tc.checks) + + assert.Equal(t, tc.want, report.ExitCode()) + }) + } +} + +func TestReport_OverallStatus(t *testing.T) { + cases := []struct { + name string + checks []Result + want string + }{ + {"no checks", nil, "ok"}, + {"all ok", []Result{{CheckID: "a", Status: StatusOK}}, "ok"}, + {"skip only counts as ok", []Result{{CheckID: "a", Status: StatusSKIP}}, "ok"}, + {"warn present", []Result{{CheckID: "a", Status: StatusWARN}}, "warn"}, + {"fail beats warn", []Result{ + {CheckID: "a", Status: StatusWARN}, + {CheckID: "b", Status: StatusFAIL}, + }, "fail"}, + {"fail beats ok", []Result{ + {CheckID: "a", Status: StatusOK}, + {CheckID: "b", Status: StatusFAIL}, + }, "fail"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + report := NewReport("/tmp/x", nil, tc.checks) + + assert.Equal(t, tc.want, report.OverallStatus()) + }) + } +} + +func TestReport_Counts(t *testing.T) { + checks := []Result{ + {CheckID: "a", Status: StatusOK}, + {CheckID: "b", Status: StatusOK}, + {CheckID: "c", Status: StatusWARN}, + {CheckID: "d", Status: StatusFAIL}, + {CheckID: "e", Status: StatusSKIP}, + {CheckID: "f", Status: StatusSKIP}, + } + + report := NewReport("/tmp/x", nil, checks) + + assert.Equal(t, Counts{OK: 2, WARN: 1, FAIL: 1, SKIP: 2}, report.Counts()) +} + +func TestReport_CountsAlwaysMatchChecks(t *testing.T) { + // For every combination of statuses, the tally must equal the checks slice. + statuses := []Status{StatusOK, StatusWARN, StatusFAIL, StatusSKIP} + + for _, a := range statuses { + for _, b := range statuses { + checks := []Result{ + {CheckID: "a", Status: a}, + {CheckID: "b", Status: b}, + } + + got := NewReport("/tmp/x", nil, checks).Counts() + + var want Counts + + for _, c := range checks { + switch c.Status { + case StatusOK: + want.OK++ + case StatusWARN: + want.WARN++ + case StatusFAIL: + want.FAIL++ + case StatusSKIP: + want.SKIP++ + } + } + + assert.Equal(t, want, got, "statuses %s/%s", a, b) + } + } +} + +func TestAction_Statuses(t *testing.T) { + // The repair-run action vocabulary is pinned by the output contract. + assert.Equal(t, ActionPerformed, ActionStatus("performed")) + assert.Equal(t, ActionSkipped, ActionStatus("skipped")) + assert.Equal(t, ActionNotNeeded, ActionStatus("not-needed")) +} diff --git a/internal/doctor/json.go b/internal/doctor/json.go new file mode 100644 index 000000000..9340e505c --- /dev/null +++ b/internal/doctor/json.go @@ -0,0 +1,78 @@ +// 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 doctor + +import ( + "encoding/json" + "io" +) + +// jsonCheck is the pinned per-check JSON shape: uppercase status, id, summary, +// optional remedy and details, and the fixable flag. +type jsonCheck struct { + ID string `json:"id"` + Status Status `json:"status"` + Summary string `json:"summary"` + Remedy string `json:"remedy,omitempty"` + Details map[string]string `json:"details,omitempty"` + Fixable bool `json:"fixable"` +} + +// jsonReport is the pinned top-level JSON shape: absolute projectDir, +// artifactId (null when unlinked), lowercase status, checks in runner order, +// per-status summary counts, and — for repair runs only — an actions array. +type jsonReport struct { + ProjectDir string `json:"projectDir"` + ArtifactID *string `json:"artifactId"` + Status string `json:"status"` + Checks []jsonCheck `json:"checks"` + Summary Counts `json:"summary"` + Actions *[]Action `json:"actions,omitempty"` +} + +// WriteJSON renders a report as a single pure-JSON object (indented, trailing +// newline). HTML escaping is disabled so remedy strings like +// "--relink " survive verbatim. Read-only runs omit the +// "actions" key; repair runs always include it. +func WriteJSON(w io.Writer, report Report) error { + checks := make([]jsonCheck, 0, len(report.Checks)) + + for _, res := range report.Checks { + checks = append(checks, jsonCheck{ + ID: res.CheckID, + Status: res.Status, + Summary: res.Summary, + Remedy: res.Remedy, + Details: res.Details, + Fixable: res.Fixable, + }) + } + + out := jsonReport{ + ProjectDir: report.ProjectDir, + ArtifactID: report.artifactIDForJSON(), + Status: report.OverallStatus(), + Checks: checks, + Summary: report.Counts(), + Actions: report.Actions, + } + + enc := json.NewEncoder(w) + + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + + return enc.Encode(out) +} diff --git a/internal/doctor/report.go b/internal/doctor/report.go new file mode 100644 index 000000000..b0f0feb3c --- /dev/null +++ b/internal/doctor/report.go @@ -0,0 +1,88 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doctor + +// Report is the complete outcome of one doctor run: the run's context plus +// every check result in execution order. Reporters consume this struct; the +// owning command layer fills in ProjectDir and ArtifactID. +type Report struct { + // ProjectDir is the resolved absolute path of the diagnosed project. + ProjectDir string + + // ArtifactID is the linked artifact id, or nil when unlinked. Reporters + // render nil as JSON null / "not linked"; an empty string is normalized + // to unlinked (empty ≈ nil). + ArtifactID *string + + // Checks holds the results in runner order. + Checks []Result + + // Actions is nil for read-only runs (the JSON "actions" key is omitted). + // For repair runs it points at the per-repair outcomes; it is a pointer so + // a repair run with zero actions still renders "actions": []. + Actions *[]Action +} + +// NewReport builds a Report from a runner's results. The returned report is a +// read-only run (Actions nil); repair runs set Actions themselves. +func NewReport(projectDir string, artifactID *string, checks []Result) Report { + return Report{ + ProjectDir: projectDir, + ArtifactID: artifactID, + Checks: checks, + } +} + +// Counts tallies the report's checks by status. +func (r Report) Counts() Counts { + return CountResults(r.Checks) +} + +// OverallStatus derives the lowercase top-level verdict: "fail" if any check +// FAILed, else "warn" if any WARNed, else "ok" (SKIP-only counts as ok). +func (r Report) OverallStatus() string { + return OverallStatus(r.Checks) +} + +// ExitCode is 1 if any check FAILed, else 0 (OK/WARN/SKIP are allowed). +func (r Report) ExitCode() int { + if r.Counts().FAIL > 0 { + return 1 + } + + return 0 +} + +// linkedArtifact returns the artifact id for display, or "" when unlinked +// (empty ≈ nil normalization). +func (r Report) linkedArtifact() string { + if r.ArtifactID == nil || *r.ArtifactID == "" { + return "" + } + + return *r.ArtifactID +} + +// artifactIDForJSON returns the artifact id pointer to serialize: nil +// (→ JSON null) when unlinked or when the id is an empty string. +func (r Report) artifactIDForJSON() *string { + if r.linkedArtifact() == "" { + return nil + } + + id := *r.ArtifactID + + return &id +} diff --git a/internal/doctor/reporters_test.go b/internal/doctor/reporters_test.go new file mode 100644 index 000000000..64f03715e --- /dev/null +++ b/internal/doctor/reporters_test.go @@ -0,0 +1,329 @@ +// 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 doctor + +import ( + "bytes" + "encoding/json" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ansiPattern matches ANSI SGR escape sequences so tests can assert on plain text. +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(s string) string { + return ansiPattern.ReplaceAllString(s, "") +} + +// sampleReport returns a report exercising every status plus details and actions. +func sampleReport() Report { + artifact := "abc123" + + remedy := "dr artifact code doctor --relink & sync" + + checks := []Result{ + {CheckID: "wapi.presence", Status: StatusOK, Summary: "linked"}, + {CheckID: "wapi.config", Status: StatusOK, Summary: "valid"}, + {CheckID: "wapi.manifest", Status: StatusWARN, Summary: "rebuildable", Remedy: "dr artifact code doctor --fix", Fixable: true}, + {CheckID: "wapi.divergence", Status: StatusFAIL, Summary: "diverged", Remedy: remedy, Details: map[string]string{"path": "/tmp/x/manifest.json"}, Fixable: true}, + {CheckID: "wapi.lock", Status: StatusSKIP, Summary: "not enforced"}, + } + + return NewReport("/tmp/x", &artifact, checks) +} + +func TestTextReporter_HeaderTableRemediesSummary(t *testing.T) { + var buf bytes.Buffer + + require.NoError(t, WriteText(&buf, sampleReport())) + + out := stripANSI(buf.String()) + + // Header: absolute project dir + linked artifact id. + assert.Contains(t, out, "/tmp/x") + assert.Contains(t, out, "abc123") + + // Table headers and one row per check (in runner order). + for _, want := range []string{"CHECK", "STATUS", "DETAIL", "wapi.presence", "wapi.config", "wapi.manifest", "wapi.divergence", "wapi.lock", "OK", "WARN", "FAIL", "SKIP"} { + assert.Contains(t, out, want) + } + + // Order check: presence row appears before divergence row. + assert.Less(t, strings.Index(out, "wapi.presence"), strings.Index(out, "wapi.divergence")) + + // Remedies rendered for non-OK rows. + assert.Contains(t, out, "dr artifact code doctor --fix") + assert.Contains(t, out, "dr artifact code doctor --relink & sync") + + // Summary line: counts plus verdict. + assert.Contains(t, out, "2 ok") + assert.Contains(t, out, "1 warn") + assert.Contains(t, out, "1 fail") + assert.Contains(t, out, "1 skip") + assert.Contains(t, out, "verdict: fail") +} + +func TestTextReporter_NotLinked(t *testing.T) { + var buf bytes.Buffer + + require.NoError(t, WriteText(&buf, NewReport("/tmp/x", nil, nil))) + + out := stripANSI(buf.String()) + + assert.Contains(t, out, "not linked") + assert.NotContains(t, out, "abc123") +} + +func TestTextReporter_NoRemediesWhenAllOK(t *testing.T) { + var buf bytes.Buffer + + report := NewReport("/tmp/x", nil, []Result{{CheckID: "a.b", Status: StatusOK, Summary: "fine"}}) + + require.NoError(t, WriteText(&buf, report)) + + out := stripANSI(buf.String()) + + assert.Contains(t, out, "verdict: ok") + assert.NotContains(t, out, "Remedies") +} + +func TestTextReporter_DetailsInRow(t *testing.T) { + var buf bytes.Buffer + + require.NoError(t, WriteText(&buf, sampleReport())) + + out := stripANSI(buf.String()) + + // details.path surfaces in the human-readable DETAIL column. + assert.Contains(t, out, "path: /tmp/x/manifest.json") +} + +func TestJSONReporter_Schema(t *testing.T) { + var buf bytes.Buffer + + report := sampleReport() + + report.Actions = &[]Action{{ID: "wapi.manifest", Status: ActionSkipped, Reason: "sync in progress"}} + + require.NoError(t, WriteJSON(&buf, report)) + + // Raw-bytes check (BEFORE json.Unmarshal): SetEscapeHTML(false) must leave + // <, >, and & verbatim in the marshaled output. The post-Unmarshal checks + // below are escaping-invariant (Decode reverses HTML escaping), so only + // this check pins the encoder configuration documented in json.go. + raw := buf.String() + + assert.Contains(t, raw, "") + assert.Contains(t, raw, "& sync") + assert.NotContains(t, raw, `\u003c`) + assert.NotContains(t, raw, `\u003e`) + assert.NotContains(t, raw, `\u0026`) + + var got map[string]any + + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + + assert.Equal(t, "/tmp/x", got["projectDir"]) + assert.Equal(t, "abc123", got["artifactId"]) + assert.Equal(t, "fail", got["status"]) + + summary, ok := got["summary"].(map[string]any) + + require.True(t, ok) + + // JSON numbers decode as float64; InDelta keeps testifylint's + // float-compare rule happy. + assert.InDelta(t, 2, summary["ok"], 0) + assert.InDelta(t, 1, summary["warn"], 0) + assert.InDelta(t, 1, summary["fail"], 0) + assert.InDelta(t, 1, summary["skip"], 0) + + // Checks array in runner order, uppercase per-check status. + rawChecks, ok := got["checks"].([]any) + + require.True(t, ok) + + require.Len(t, rawChecks, 5) + + wantIDs := []string{"wapi.presence", "wapi.config", "wapi.manifest", "wapi.divergence", "wapi.lock"} + + for i, raw := range rawChecks { + check, ok := raw.(map[string]any) + + require.True(t, ok) + + assert.Equal(t, wantIDs[i], check["id"]) + } + + diverged := rawChecks[3].(map[string]any) + + assert.Equal(t, "FAIL", diverged["status"]) + assert.Equal(t, "diverged", diverged["summary"]) + assert.Equal(t, "dr artifact code doctor --relink & sync", diverged["remedy"]) + assert.Equal(t, true, diverged["fixable"]) + + details, ok := diverged["details"].(map[string]any) + + require.True(t, ok) + + assert.Equal(t, "/tmp/x/manifest.json", details["path"]) + + // Actions present for repair runs. + actions, ok := got["actions"].([]any) + + require.True(t, ok) + + require.Len(t, actions, 1) + + action := actions[0].(map[string]any) + + assert.Equal(t, "wapi.manifest", action["id"]) + assert.Equal(t, "skipped", action["status"]) + assert.Equal(t, "sync in progress", action["reason"]) +} + +func TestJSONReporter_PureSingleJSONObject(t *testing.T) { + var buf bytes.Buffer + + require.NoError(t, WriteJSON(&buf, sampleReport())) + + out := buf.String() + + assert.True(t, json.Valid([]byte(out))) + assert.True(t, strings.HasPrefix(strings.TrimSpace(out), "{")) + assert.True(t, strings.HasSuffix(strings.TrimSpace(out), "}")) +} + +// TestJSONReporter_SingleObjectSecondDecodeEOF pins "exactly one JSON object": +// a second decode of the same stream must hit EOF. +func TestJSONReporter_SingleObjectSecondDecodeEOF(t *testing.T) { + var buf bytes.Buffer + + require.NoError(t, WriteJSON(&buf, sampleReport())) + + dec := json.NewDecoder(bytes.NewReader(buf.Bytes())) + + var obj map[string]any + + require.NoError(t, dec.Decode(&obj)) + + var extra map[string]any + + err := dec.Decode(&extra) + + require.Error(t, err) + + assert.Equal(t, "EOF", err.Error()) +} + +func TestJSONReporter_ArtifactIDNullWhenUnlinked(t *testing.T) { + var buf bytes.Buffer + + report := NewReport("/tmp/x", nil, []Result{{CheckID: "a.b", Status: StatusOK, Summary: "fine"}}) + + require.NoError(t, WriteJSON(&buf, report)) + + var got map[string]any + + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + + val, present := got["artifactId"] + + assert.True(t, present, "artifactId key must be present, not omitted") + assert.Nil(t, val) +} + +func TestJSONReporter_EmptyStringArtifactIDNormalizedToNull(t *testing.T) { + // Empty ≈ nil normalization (pinned): never emit "". + empty := "" + + var buf bytes.Buffer + + require.NoError(t, WriteJSON(&buf, NewReport("/tmp/x", &empty, nil))) + + assert.Contains(t, buf.String(), "\"artifactId\": null") + assert.NotContains(t, buf.String(), "\"artifactId\": \"\"") +} + +func TestJSONReporter_ActionsOmittedForReadOnly(t *testing.T) { + var buf bytes.Buffer + + require.NoError(t, WriteJSON(&buf, sampleReport())) + + assert.NotContains(t, buf.String(), "actions") +} + +func TestJSONReporter_ActionsEmptyArrayForRepairRun(t *testing.T) { + // A repair run with nothing to do still emits an (empty) actions array. + var buf bytes.Buffer + + report := sampleReport() + + report.Actions = &[]Action{} + + require.NoError(t, WriteJSON(&buf, report)) + + var got map[string]any + + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + + actions, present := got["actions"] + + require.True(t, present, "actions key must be present for repair runs") + + assert.Empty(t, actions) +} + +func TestJSONReporter_StatusCasing(t *testing.T) { + // Top-level status lowercase, per-check status uppercase (pinned). + var buf bytes.Buffer + + report := NewReport("/tmp/x", nil, []Result{{CheckID: "a.b", Status: StatusWARN, Summary: "meh"}}) + + require.NoError(t, WriteJSON(&buf, report)) + + var got map[string]any + + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + + assert.Equal(t, "warn", got["status"]) + + checks := got["checks"].([]any) + + assert.Equal(t, "WARN", checks[0].(map[string]any)["status"]) +} + +func TestJSONReporter_NoDetailsKeyWhenNil(t *testing.T) { + var buf bytes.Buffer + + require.NoError(t, WriteJSON(&buf, sampleReport())) + + var got map[string]any + + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + + checks := got["checks"].([]any) + + presence := checks[0].(map[string]any) + + _, hasDetails := presence["details"] + + assert.False(t, hasDetails, "details must be omitted when nil") +} diff --git a/internal/doctor/runner.go b/internal/doctor/runner.go new file mode 100644 index 000000000..a16a1bbc2 --- /dev/null +++ b/internal/doctor/runner.go @@ -0,0 +1,90 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doctor + +import "context" + +// Runner executes an ordered list of checks. Order is the caller's +// responsibility and is preserved end-to-end: results and both reporters +// render checks in construction order. +type Runner struct { + checks []Check +} + +// NewRunner builds a Runner that executes the given checks in the given order. +func NewRunner(checks ...Check) *Runner { + return &Runner{checks: checks} +} + +// Run executes every check in construction order and returns the results in +// the same order. Each result's CheckID is stamped from the check itself, so +// reporters never render an anonymous row. +func (r *Runner) Run(ctx context.Context) []Result { + results := make([]Result, 0, len(r.checks)) + + for _, check := range r.checks { + res := check.Run(ctx) + + res.CheckID = check.ID() + + results = append(results, res) + } + + return results +} + +// Counts is the per-status tally of a run's checks, serialized with the +// pinned lowercase keys in the JSON summary. +type Counts struct { + OK int `json:"ok"` + WARN int `json:"warn"` + FAIL int `json:"fail"` + SKIP int `json:"skip"` +} + +// OverallStatus derives the run's top-level verdict: "fail" if any check +// FAILed, else "warn" if any WARNed, else "ok" (SKIP-only counts as ok). +func OverallStatus(checks []Result) string { + counts := CountResults(checks) + + switch { + case counts.FAIL > 0: + return "fail" + case counts.WARN > 0: + return "warn" + default: + return "ok" + } +} + +// CountResults tallies checks by status. +func CountResults(checks []Result) Counts { + var counts Counts + + for _, res := range checks { + switch res.Status { + case StatusOK: + counts.OK++ + case StatusWARN: + counts.WARN++ + case StatusFAIL: + counts.FAIL++ + case StatusSKIP: + counts.SKIP++ + } + } + + return counts +} diff --git a/internal/doctor/text.go b/internal/doctor/text.go new file mode 100644 index 000000000..aa17fd6aa --- /dev/null +++ b/internal/doctor/text.go @@ -0,0 +1,211 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doctor + +import ( + "fmt" + "io" + "slices" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + + "github.com/datarobot/cli/tui" +) + +// WriteText renders a report in human-readable form: a header (project dir +// and linked artifact or "not linked"), a CHECK/STATUS/DETAIL table with one +// row per check in runner order, remedies for non-OK rows, and a summary +// line with per-status counts plus the overall verdict. +func WriteText(w io.Writer, report Report) error { + artifact := report.linkedArtifact() + if artifact == "" { + artifact = "not linked" + } + + if _, err := fmt.Fprintf(w, "Doctor report for %s — artifact: %s\n\n", report.ProjectDir, artifact); err != nil { + return err + } + + if err := writeChecksTable(w, report); err != nil { + return err + } + + if err := writeRemedies(w, report); err != nil { + return err + } + + if err := writeActions(w, report); err != nil { + return err + } + + return writeSummary(w, report) +} + +// writeChecksTable renders the per-check table using the repo-standard +// lipgloss table styling. +func writeChecksTable(w io.Writer, report Report) error { + statusByRow := make(map[int]Status, len(report.Checks)) + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + statusCol := 1 + + 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 == statusCol { + switch statusByRow[row] { + case StatusOK: + return tui.SuccessStyle.Padding(0, 1) + case StatusWARN: + return tui.WarnStyle.Padding(0, 1) + case StatusFAIL: + return tui.ErrorStyle.Padding(0, 1) + case StatusSKIP: + return tui.DimStyle.Padding(0, 1) + } + } + + return cellStyle + }). + Headers("CHECK", "STATUS", "DETAIL") + + for i, res := range report.Checks { + statusByRow[i] = res.Status + + t.Row(res.CheckID, string(res.Status), renderDetail(res)) + } + + _, err := fmt.Fprintln(w, t.Render()) + + return err +} + +// renderDetail builds the DETAIL cell: the summary plus any structured +// details (e.g. "path: /abs/file") on their own lines, keys sorted for +// deterministic output. +func renderDetail(res Result) string { + parts := make([]string, 0, len(res.Details)+1) + + parts = append(parts, res.Summary) + + keys := make([]string, 0, len(res.Details)) + + for k := range res.Details { + keys = append(keys, k) + } + + slices.Sort(keys) + + for _, k := range keys { + parts = append(parts, k+": "+res.Details[k]) + } + + return strings.Join(parts, "\n") +} + +// writeRemedies prints the remedy for each non-OK check that carries one. +func writeRemedies(w io.Writer, report Report) error { + remedies := make([]string, 0, len(report.Checks)) + + for _, res := range report.Checks { + if res.Status == StatusOK || res.Remedy == "" { + continue + } + + remedies = append(remedies, fmt.Sprintf(" %s: %s", res.CheckID, res.Remedy)) + } + + if len(remedies) == 0 { + return nil + } + + if _, err := fmt.Fprintln(w, "\nRemedies"); err != nil { + return err + } + + for _, r := range remedies { + if _, err := fmt.Fprintln(w, r); err != nil { + return err + } + } + + return nil +} + +// writeActions prints the per-repair outcomes of a repair run (--fix / +// --relink); read-only runs (Actions nil) print nothing. When every repair +// reported not-needed, the section says so explicitly so a --fix on a healthy +// project is unambiguously a no-op. +func writeActions(w io.Writer, report Report) error { + if report.Actions == nil { + return nil + } + + actions := *report.Actions + + if _, err := fmt.Fprintln(w, "\nRepairs"); err != nil { + return err + } + + if len(actions) == 0 { + _, err := fmt.Fprintln(w, " nothing to fix: no repairs needed") + + return err + } + + allNotNeeded := true + + for _, action := range actions { + if action.Status != ActionNotNeeded { + allNotNeeded = false + } + + line := fmt.Sprintf(" %s: %s", action.ID, action.Status) + + if action.Reason != "" { + line += " — " + action.Reason + } + + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + + if allNotNeeded { + _, err := fmt.Fprintln(w, " nothing to fix: no repairs needed") + + return err + } + + return nil +} + +// writeSummary prints the per-status counts and the overall verdict. +func writeSummary(w io.Writer, report Report) error { + counts := report.Counts() + + _, err := fmt.Fprintf(w, "\nSummary: %d ok, %d warn, %d fail, %d skip — verdict: %s\n", + counts.OK, counts.WARN, counts.FAIL, counts.SKIP, report.OverallStatus()) + + return err +} diff --git a/internal/misc/reader/reader.go b/internal/misc/reader/reader.go index 57aeec77d..0902dd857 100644 --- a/internal/misc/reader/reader.go +++ b/internal/misc/reader/reader.go @@ -77,6 +77,15 @@ func ReadString() (string, error) { str, err := readLine(reader) if err != nil { + // On a read error (Ctrl-C, EOF, cancelreader failure) print a bare + // newline to stdout so the terminal cursor moves off the prompt line. + // This is a cosmetic edge for JSON purity: in --output-format json + // mode a Ctrl-C at an interactive prompt can emit this stray newline + // on stdout. Abort paths emit no JSON report anyway (no + // invalid-JSON-following-JSON scenario exists today), but reader + // prompt helpers are not fully JSON-mode-safe as-is. Behavior is + // intentionally unchanged — do not remove this newline without + // auditing all call sites for terminal cursor positioning. fmt.Println() } diff --git a/internal/workload/doctor/config.go b/internal/workload/doctor/config.go new file mode 100644 index 000000000..814478640 --- /dev/null +++ b/internal/workload/doctor/config.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 doctor + +import ( + "context" + "errors" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// configCheck verifies that config.json parses and passes wapi's semantic +// validation (including the coupled catalogId/lastSyncedVersionId rule). +type configCheck struct { + projectDir string +} + +func (c *configCheck) ID() string { + return CheckIDConfig +} + +func (c *configCheck) Name() string { + return "Config file" +} + +// Run loads the config read-only. A missing file and a parse/validation +// failure are both FAILs carrying the absolute file path in details.path; +// an unlinked project SKIPs (presence cascade). +func (c *configCheck) Run(_ context.Context) core.Result { + if res, skip := skipIfUnlinked(c.projectDir); skip { + return res + } + + _, err := wapi.LoadConfig(c.projectDir) + + switch { + case err == nil: + return core.Result{ + Status: core.StatusOK, + Summary: "config.json is valid", + } + case errors.Is(err, wapi.ErrNotInitialized): + // The state directory exists but config.json does not. + return corruptFileResult("config.json is missing", wapi.ConfigPath(c.projectDir), RemedyConfig, false) + default: + return corruptFileResult( + "config.json is corrupt: "+corruptReason(err), + stateErrPath(err, wapi.ConfigPath(c.projectDir)), + RemedyConfig, + false, + ) + } +} diff --git a/internal/workload/doctor/config_test.go b/internal/workload/doctor/config_test.go new file mode 100644 index 000000000..b0d21737e --- /dev/null +++ b/internal/workload/doctor/config_test.go @@ -0,0 +1,105 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doctor + +import ( + "context" + "path/filepath" + "testing" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigCheck_OK(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig(testCatalogID, testVersionID))) + + res := (&configCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusOK, res.Status) + + assert.Empty(t, res.Remedy) +} + +func TestConfigCheck_FAIL_Missing(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + res := (&configCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + assert.Contains(t, res.Summary, "missing") + + assert.Equal(t, RemedyConfig, res.Remedy) + + wantPath, err := filepath.Abs(wapi.ConfigPath(dir)) + + require.NoError(t, err) + + assert.Equal(t, wantPath, res.Details["path"]) +} + +func TestConfigCheck_FAIL_CorruptShowsAbsolutePath(t *testing.T) { + dir := t.TempDir() + + writeStateFile(t, dir, "config.json", `{"artifactId":"abc`) + + res := (&configCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + wantPath, err := filepath.Abs(wapi.ConfigPath(dir)) + + require.NoError(t, err) + + assert.Equal(t, wantPath, res.Details["path"]) + + assert.Contains(t, res.Summary, wantPath) +} + +func TestConfigCheck_FAIL_SemanticValidation(t *testing.T) { + dir := t.TempDir() + + // Parses fine but fails coupled-field validation: lastSyncedVersionId + // set while catalogId is null. + writeStateFile(t, dir, "config.json", + `{"artifactId":"`+testArtifactID+`","lastSyncedVersionId":"`+testVersionID+`","createdAt":"2026-01-01T00:00:00Z","cliVersion":"test-version"}`) + + res := (&configCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + wantPath, err := filepath.Abs(wapi.ConfigPath(dir)) + + require.NoError(t, err) + + assert.Equal(t, wantPath, res.Details["path"]) +} + +func TestConfigCheck_SKIP_NotLinked(t *testing.T) { + res := (&configCheck{projectDir: t.TempDir()}).Run(context.Background()) + + assert.Equal(t, core.StatusSKIP, res.Status) + + assert.Contains(t, res.Summary, "no linked state") +} diff --git a/internal/workload/doctor/divergence.go b/internal/workload/doctor/divergence.go new file mode 100644 index 000000000..9161b47f0 --- /dev/null +++ b/internal/workload/doctor/divergence.go @@ -0,0 +1,97 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doctor + +import ( + "context" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// divergenceCheck verifies that the config's lastSyncedVersionId and the +// manifest's syncedVersionId agree, including nil-ness (both null is a +// healthy "never synced" state). +type divergenceCheck struct { + projectDir string +} + +func (c *divergenceCheck) ID() string { + return CheckIDDivergence +} + +func (c *divergenceCheck) Name() string { + return "Config/manifest sync pointers" +} + +// Run compares the two sync pointers with empty-string normalization. It +// SKIPs when the project is unlinked or when either side cannot be loaded — +// a corrupt file is that file's check's FAIL, not a divergence. +func (c *divergenceCheck) Run(_ context.Context) core.Result { + if res, skip := skipIfUnlinked(c.projectDir); skip { + return res + } + + cfg, err := wapi.LoadConfig(c.projectDir) + if err != nil { + return core.Result{ + Status: core.StatusSKIP, + Summary: "config.json is missing or corrupt; sync pointers cannot be compared", + } + } + + manifest, err := wapi.LoadManifest(c.projectDir) + if err != nil { + return core.Result{ + Status: core.StatusSKIP, + Summary: "manifest.json is missing or corrupt; sync pointers cannot be compared", + } + } + + cfgPtr := normalizeStringPtr(cfg.LastSyncedVersionID) + + manifestPtr := normalizeStringPtr(manifest.SyncedVersionID) + + if pointersAgree(cfgPtr, manifestPtr) { + return core.Result{ + Status: core.StatusOK, + Summary: "config and manifest sync pointers agree", + } + } + + return core.Result{ + Status: core.StatusFAIL, + Summary: "config and manifest sync pointers diverge (config lastSyncedVersionId: " + + ptrDisplay(cfg.LastSyncedVersionID) + + ", manifest syncedVersionId: " + + ptrDisplay(manifest.SyncedVersionID) + ")", + Remedy: RemedyDivergence, + Details: map[string]string{ + "configLastSyncedVersionId": ptrDisplay(cfg.LastSyncedVersionID), + "manifestSyncedVersionId": ptrDisplay(manifest.SyncedVersionID), + }, + Fixable: true, + } +} + +// pointersAgree reports whether the two normalized pointers describe the +// same sync state: both absent, or both present and equal. +func pointersAgree(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + + return *a == *b +} diff --git a/internal/workload/doctor/divergence_test.go b/internal/workload/doctor/divergence_test.go new file mode 100644 index 000000000..3f9a4d774 --- /dev/null +++ b/internal/workload/doctor/divergence_test.go @@ -0,0 +1,102 @@ +// 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 doctor + +import ( + "context" + "testing" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDivergenceCheck_PointerMatrix(t *testing.T) { + tests := []struct { + name string + cfgVersionID string + manifestVersionID string + want core.Status + }{ + {"both null", "", "", core.StatusOK}, + {"both set and equal", testVersionID, testVersionID, core.StatusOK}, + {"config set, manifest null", testVersionID, "", core.StatusFAIL}, + {"config null, manifest set", "", testVersionID, core.StatusFAIL}, + {"both set but different", testVersionID, "ffffffffffffffffffffffff", core.StatusFAIL}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig(testCatalogID, tt.cfgVersionID))) + + require.NoError(t, wapi.SaveManifest(dir, validManifest(tt.manifestVersionID))) + + res := (&divergenceCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, tt.want, res.Status) + + if tt.want == core.StatusFAIL { + assert.Equal(t, RemedyDivergence, res.Remedy) + + assert.True(t, res.Fixable) + } + }) + } +} + +func TestDivergenceCheck_SKIP_ConfigUnreadable(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + writeStateFile(t, dir, "manifest.json", `{"version":1,"syncedAt":null,"syncedVersionId":null,"files":{}}`) + + res := (&divergenceCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusSKIP, res.Status) +} + +func TestDivergenceCheck_SKIP_ManifestUnreadable(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig(testCatalogID, testVersionID))) + + res := (&divergenceCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusSKIP, res.Status) +} + +func TestDivergenceCheck_SKIP_NotLinked(t *testing.T) { + res := (&divergenceCheck{projectDir: t.TempDir()}).Run(context.Background()) + + assert.Equal(t, core.StatusSKIP, res.Status) + + assert.Contains(t, res.Summary, "no linked state") +} + +func TestNormalizeStringPtr(t *testing.T) { + assert.Nil(t, normalizeStringPtr(nil)) + + assert.Nil(t, normalizeStringPtr(strPtr(""))) + + assert.Equal(t, strPtr(testVersionID), normalizeStringPtr(strPtr(testVersionID))) +} diff --git a/internal/workload/doctor/doc.go b/internal/workload/doctor/doc.go new file mode 100644 index 000000000..73490fd67 --- /dev/null +++ b/internal/workload/doctor/doc.go @@ -0,0 +1,24 @@ +// 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 doctor implements the wapi-specific checks for +// `dr artifact code doctor`: read-only diagnostics over the project's sync +// state in /.datarobot/workload (legacy: .wapi/). +// +// Every check is a pure diagnostic: it performs zero local writes and zero +// network calls. Repairs live behind `--fix`/`--relink` in the command layer. +// The generic Check/Result/Runner framework these checks plug into lives in +// internal/doctor (imported here as core to avoid clashing with this +// package's name). +package doctor diff --git a/internal/workload/doctor/fix.go b/internal/workload/doctor/fix.go new file mode 100644 index 000000000..88de3144d --- /dev/null +++ b/internal/workload/doctor/fix.go @@ -0,0 +1,267 @@ +// 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 doctor + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "time" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/fsutil" + "github.com/datarobot/cli/internal/workload/sync" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// Skip reasons shared by the repair operations. The sync-in-progress reason +// is part of the --fix output contract (every gated repair carries it), so +// it must not be reworded. +const ( + // ReasonSyncInProgress is the skip reason every repair reports when the + // global safety gate finds a live process holding the sync lock. A sync + // writes manifest.json in its final phase, so no repair is safe under it. + ReasonSyncInProgress = "sync in progress: another process holds the sync lock" + + // ReasonLockUninspectable is the skip reason every repair reports when + // the lock cannot be inspected: a hidden live sync cannot be ruled out. + ReasonLockUninspectable = "cannot inspect the sync lock; a sync may be in progress, so no repair is safe" + + // ReasonNoLinkedState is the skip reason for repairs that need linked + // state (a readable config) when the project has none. + ReasonNoLinkedState = "no linked state; run 'dr artifact code init ' first" +) + +// RunFix executes the `doctor --fix` repair suite for projectDir and returns +// one action per repair in the pinned order: manifest rebuild, rollback +// clear, lock clear. +// +// Global safety gate: the sync lock is probed first (non-creating probe, +// same logic as the wapi.lock check). When a live process holds the lock — +// or it cannot be inspected — ALL repairs are skipped with a reason, because +// a sync writes manifest.json in its final phase and must never be repaired +// underneath. --fix never touches the server; every write here is local. +func RunFix(ctx context.Context, projectDir string) []core.Action { + return runFixWithGoos(ctx, projectDir, runtime.GOOS) +} + +// runFixWithGoos is RunFix with the platform seam injected, so the windows +// gate path (flock not enforced) stays unit-testable on any host. +func runFixWithGoos(ctx context.Context, projectDir, goos string) []core.Action { + switch gate := newLockCheckWithGoos(projectDir, goos).Run(ctx); gate.Status { + case core.StatusFAIL: + return skipAllRepairs(ReasonSyncInProgress) + case core.StatusWARN: + return skipAllRepairs(ReasonLockUninspectable) + case core.StatusSKIP: + // Windows (flock is not enforced there, per RAPTOR-16928) or a + // project with no linked state: no live holder can exist or be + // detected, so the gate lets the repairs through. + case core.StatusOK: + // Nothing held (or no lock file at all): the gate is open. + } + + return []core.Action{ + fixManifest(projectDir), + fixRollback(projectDir), + fixLock(projectDir), + } +} + +// skipAllRepairs reports every repair as skipped with the given reason while +// the global safety gate blocks the run. +func skipAllRepairs(reason string) []core.Action { + ids := []string{CheckIDManifest, CheckIDRollback, CheckIDLock} + + actions := make([]core.Action, 0, len(ids)) + + for _, id := range ids { + actions = append(actions, core.Action{ID: id, Status: core.ActionSkipped, Reason: reason}) + } + + return actions +} + +// fixManifest rebuilds manifest.json as an empty BASE derived from config: +// Manifest{Version: 1, SyncedAt/SyncedVersionID nil-iff-config-nil, +// SyncedVersionID: cfg.LastSyncedVersionID, Files: {}}. The working tree is +// never touched. It requires a valid config: a corrupt config cannot name +// what the manifest should say, so the repair is skipped with a re-init +// remedy. +func fixManifest(projectDir string) core.Action { + cfg, err := wapi.LoadConfig(projectDir) + if err != nil { + if errors.Is(err, wapi.ErrNotInitialized) { + return core.Action{ID: CheckIDManifest, Status: core.ActionSkipped, Reason: ReasonNoLinkedState} + } + + return core.Action{ + ID: CheckIDManifest, + Status: core.ActionSkipped, + Reason: fmt.Sprintf( + "config.json is corrupt or invalid (%s); the manifest cannot be rebuilt — re-initialize with 'dr artifact code init '", + corruptReason(err), + ), + } + } + + manifest, err := wapi.LoadManifest(projectDir) + + needsRebuild := err != nil || manifestDivergesFromConfig(cfg, manifest) + + if !needsRebuild { + return core.Action{ID: CheckIDManifest, Status: core.ActionNotNeeded} + } + + rebuilt := wapi.Manifest{ + Version: wapi.ManifestVersion, + Files: map[string]wapi.FileMeta{}, + } + + // Both-or-neither: the synced pointers are only written as a pair, so a + // config with a last-synced version yields a manifest with both set. + if versionID := normalizeStringPtr(cfg.LastSyncedVersionID); versionID != nil { + now := time.Now().UTC() + + rebuilt.SyncedAt = &now + + rebuilt.SyncedVersionID = versionID + } + + if err := wapi.SaveManifest(projectDir, rebuilt); err != nil { + return core.Action{ + ID: CheckIDManifest, + Status: core.ActionSkipped, + Reason: fmt.Sprintf("write rebuilt manifest: %v", err), + } + } + + return core.Action{ + ID: CheckIDManifest, + Status: core.ActionPerformed, + Reason: "rebuilt manifest.json as an empty BASE from config.json; the next sync re-establishes the baseline", + } +} + +// manifestDivergesFromConfig reports whether the loaded manifest disagrees +// with the config's last-synced pointer (empty ≈ nil normalized). A missing +// or corrupt manifest is handled by the caller before this is consulted. +func manifestDivergesFromConfig(cfg wapi.Config, manifest wapi.Manifest) bool { + return !pointersAgree( + normalizeStringPtr(cfg.LastSyncedVersionID), + normalizeStringPtr(manifest.SyncedVersionID), + ) +} + +// fixRollback clears an interrupted rollback by restoring the backed-up +// files to the working tree and removing the .rollback/ tree(s). With no +// rollback tree present it reports not-needed. +func fixRollback(projectDir string) core.Action { + present := false + + for _, dir := range wapi.StaleRollbackDirs(projectDir) { + if fsutil.DirExists(dir) { + present = true + + break + } + } + + if !present { + return core.Action{ID: CheckIDRollback, Status: core.ActionNotNeeded} + } + + restored, err := sync.RestoreStaleIfPresent(projectDir) + if err != nil { + return core.Action{ + ID: CheckIDRollback, + Status: core.ActionSkipped, + Reason: fmt.Sprintf("restore interrupted rollback: %v", err), + } + } + + if !restored { + // The tree vanished between the existence probe and the restore + // (e.g. a racing cleanup). Nothing was restored, so nothing was done. + return core.Action{ID: CheckIDRollback, Status: core.ActionNotNeeded} + } + + return core.Action{ + ID: CheckIDRollback, + Status: core.ActionPerformed, + Reason: "restored backed-up files to the working tree and removed .rollback/", + } +} + +// fixLock verifies the sync lock is clearable and leaves it untouched. An +// absent lock file is not-needed (and is never created); a present lock that +// AcquireSyncLock acquires is immediately released again and reported +// not-needed with a "verified acquirable" reason — the OS already released an +// unheld flock, so the file is the healthy steady state and nothing needed +// clearing (it is also never unlinked, because another process may hold the +// open descriptor). A lock that cannot be acquired (a holder appeared after +// the safety gate, or the file is uninspectable) is left exactly as found and +// reported skipped. +// +// Benign TOCTOU: the stat-then-acquire sequence has a race window — a sync +// could start between the stat and the AcquireSyncLock call. This is harmless: +// if a sync acquires the lock in that window, AcquireSyncLock fails and the +// repair reports skipped (the lock is held); if the lock file is created by a +// starting sync after the stat found it absent, AcquireSyncLock succeeds and +// is released, which is fine because the sync's own flock is on a different +// file descriptor (flock is per-open-file-description, not per-path). In both +// cases the post-fix check suite reports the honest state. +func fixLock(projectDir string) core.Action { + path := filepath.Join(wapi.Dir(projectDir), sync.LockFileName) + + if _, err := os.Stat(path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return core.Action{ID: CheckIDLock, Status: core.ActionNotNeeded} + } + + return core.Action{ + ID: CheckIDLock, + Status: core.ActionSkipped, + Reason: fmt.Sprintf("stat sync lock %s: %v", path, err), + } + } + + lock, err := sync.AcquireSyncLock(projectDir) + if err != nil { + return core.Action{ + ID: CheckIDLock, + Status: core.ActionSkipped, + Reason: fmt.Sprintf("sync lock could not be acquired: %v", err), + } + } + + if err := lock.Release(); err != nil { + return core.Action{ + ID: CheckIDLock, + Status: core.ActionSkipped, + Reason: fmt.Sprintf("release sync lock: %v", err), + } + } + + return core.Action{ + ID: CheckIDLock, + Status: core.ActionNotNeeded, + Reason: "verified acquirable (acquired and released); no holder detected", + } +} diff --git a/internal/workload/doctor/fix_test.go b/internal/workload/doctor/fix_test.go new file mode 100644 index 000000000..d841eb1e1 --- /dev/null +++ b/internal/workload/doctor/fix_test.go @@ -0,0 +1,660 @@ +// 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 doctor + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// actionByID indexes a repair run's actions by their check id. +func actionByID(t *testing.T, actions []core.Action) map[string]core.Action { + t.Helper() + + byID := make(map[string]core.Action, len(actions)) + + for _, a := range actions { + byID[a.ID] = a + } + + return byID +} + +// requireActionsInOrder asserts the pinned action order: manifest rebuild, +// rollback clear, lock clear. +func requireActionsInOrder(t *testing.T, actions []core.Action) { + t.Helper() + + require.Len(t, actions, 3) + + require.Equal(t, CheckIDManifest, actions[0].ID) + require.Equal(t, CheckIDRollback, actions[1].ID) + require.Equal(t, CheckIDLock, actions[2].ID) +} + +// TestRunFix_HealthyProject_AllNotNeeded verifies that on a healthy project +// every repair reports not-needed and the filesystem is left untouched (in +// particular, no sync.lock is created). +func TestRunFix_HealthyProject_AllNotNeeded(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + require.NoError(t, wapi.SaveManifest(dir, validManifest(""))) + + before := stateFileHashes(t, dir) + + actions := RunFix(context.Background(), dir) + + requireActionsInOrder(t, actions) + + for _, a := range actions { + assert.Equal(t, core.ActionNotNeeded, a.Status, "action %s", a.ID) + assert.Empty(t, a.Reason, "action %s", a.ID) + } + + assert.Equal(t, before, stateFileHashes(t, dir), "a no-op fix must not write anything") + + _, err := os.Stat(lockPath(t, dir)) + + assert.ErrorIs(t, err, os.ErrNotExist, "fix must not create sync.lock") +} + +// TestRunFix_MissingManifest_RebuiltEmptyBase verifies the rebuilt manifest is +// an empty BASE derived from config, honoring both-or-neither. +func TestRunFix_MissingManifest_RebuiltEmptyBase(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + actions := RunFix(context.Background(), dir) + + requireActionsInOrder(t, actions) + + rebuild := actionByID(t, actions)[CheckIDManifest] + + assert.Equal(t, core.ActionPerformed, rebuild.Status) + + m, err := wapi.LoadManifest(dir) + + require.NoError(t, err) + + assert.Equal(t, wapi.ManifestVersion, m.Version) + assert.Nil(t, m.SyncedVersionID, "config has no lastSyncedVersionId, so the rebuilt pointer must be nil") + assert.Nil(t, m.SyncedAt, "syncedAt must stay nil when syncedVersionId is nil (both-or-neither)") + assert.Empty(t, m.Files, "rebuild resets to an empty BASE") +} + +// TestRunFix_CorruptManifest_Rebuilt verifies truncated JSON is replaced by a +// valid empty BASE. +func TestRunFix_CorruptManifest_Rebuilt(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + writeStateFile(t, dir, "manifest.json", `{"version":1,"syncedAt":nul`) + + actions := RunFix(context.Background(), dir) + + assert.Equal(t, core.ActionPerformed, actionByID(t, actions)[CheckIDManifest].Status) + + _, err := wapi.LoadManifest(dir) + + require.NoError(t, err, "manifest must parse after the rebuild") +} + +// TestRunFix_DivergentManifest_ConfigWins verifies a valid but divergent +// manifest is reset from config, with both-or-neither honored on the rebuilt +// synced pointers. +func TestRunFix_DivergentManifest_ConfigWins(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig(testCatalogID, testVersionID))) + + // Manifest claims a different synced version than config. + require.NoError(t, wapi.SaveManifest(dir, validManifest("65f1a2b3c4d5e6f7a8b9c0ff"))) + + actions := RunFix(context.Background(), dir) + + assert.Equal(t, core.ActionPerformed, actionByID(t, actions)[CheckIDManifest].Status) + + m, err := wapi.LoadManifest(dir) + + require.NoError(t, err) + + require.NotNil(t, m.SyncedVersionID) + + assert.Equal(t, testVersionID, *m.SyncedVersionID, "config wins the divergence") + require.NotNil(t, m.SyncedAt, "syncedAt must be non-nil iff syncedVersionId is non-nil") +} + +// TestRunFix_CorruptConfig_ManifestSkippedWithReinitRemedy verifies that +// without a valid config the manifest cannot be rebuilt and the skip reason +// points at re-initialization. +func TestRunFix_CorruptConfig_ManifestSkippedWithReinitRemedy(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + writeStateFile(t, dir, "config.json", `{"artifactId":"abc`) + + actions := RunFix(context.Background(), dir) + + requireActionsInOrder(t, actions) + + byID := actionByID(t, actions) + + rebuild := byID[CheckIDManifest] + + assert.Equal(t, core.ActionSkipped, rebuild.Status) + assert.Contains(t, rebuild.Reason, "config") + assert.Contains(t, rebuild.Reason, "init", "skip reason must carry the re-init remedy") + + // The other repairs still attempt independently of the config state. + assert.Equal(t, core.ActionNotNeeded, byID[CheckIDRollback].Status) + assert.Equal(t, core.ActionNotNeeded, byID[CheckIDLock].Status) + + _, statErr := os.Stat(filepath.Join(wapi.Dir(dir), "manifest.json")) + + assert.ErrorIs(t, statErr, os.ErrNotExist, "no manifest may be written without a valid config") +} + +// TestRunFix_UnlinkedProject_SkipsManifestRestNotNeeded pins the unlinked +// behavior: no linked state means nothing to rebuild from and nothing to fix. +func TestRunFix_UnlinkedProject_SkipsManifestRestNotNeeded(t *testing.T) { + dir := t.TempDir() + + actions := RunFix(context.Background(), dir) + + requireActionsInOrder(t, actions) + + byID := actionByID(t, actions) + + rebuild := byID[CheckIDManifest] + + assert.Equal(t, core.ActionSkipped, rebuild.Status) + assert.Contains(t, rebuild.Reason, "no linked state") + + assert.Equal(t, core.ActionNotNeeded, byID[CheckIDRollback].Status) + assert.Equal(t, core.ActionNotNeeded, byID[CheckIDLock].Status) +} + +// seedRollback writes a rollback tree with one backed-up file. +func seedRollback(t *testing.T, projectDir, relPath, contents string) { + t.Helper() + + dst := filepath.Join(wapi.Dir(projectDir), ".rollback", filepath.FromSlash(relPath)) + + require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755)) + + require.NoError(t, os.WriteFile(dst, []byte(contents), 0o600)) +} + +// TestRunFix_RollbackRestoredAndRemoved verifies backed-up files return to +// their original paths and the .rollback/ tree is removed. +func TestRunFix_RollbackRestoredAndRemoved(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + require.NoError(t, wapi.SaveManifest(dir, validManifest(""))) + + seedRollback(t, dir, "app/main.go", "backed up contents") + + // The working-tree copy drifted after the backup was staged. + working := filepath.Join(dir, "app", "main.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(working), 0o755)) + + require.NoError(t, os.WriteFile(working, []byte("drifted contents"), 0o600)) + + actions := RunFix(context.Background(), dir) + + assert.Equal(t, core.ActionPerformed, actionByID(t, actions)[CheckIDRollback].Status) + + restored, err := os.ReadFile(working) + + require.NoError(t, err) + + assert.Equal(t, "backed up contents", string(restored), "the backed-up file must return to its original path") + + _, statErr := os.Stat(filepath.Join(wapi.Dir(dir), ".rollback")) + + assert.ErrorIs(t, statErr, os.ErrNotExist, ".rollback/ must be removed after the restore") +} + +// TestRunFix_RollbackRecreatesDeletedFile verifies a file the interrupted sync +// had deleted comes back from the backup tree. +func TestRunFix_RollbackRecreatesDeletedFile(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + seedRollback(t, dir, "app/removed.go", "resurrected") + + actions := RunFix(context.Background(), dir) + + assert.Equal(t, core.ActionPerformed, actionByID(t, actions)[CheckIDRollback].Status) + + restored, err := os.ReadFile(filepath.Join(dir, "app", "removed.go")) + + require.NoError(t, err) + + assert.Equal(t, "resurrected", string(restored)) +} + +// TestRunFix_EmptyRollbackDirHandled pins the empty-tree case: a bare +// .rollback/ directory still counts as an interrupted rollback and --fix +// clears it. +func TestRunFix_EmptyRollbackDirHandled(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + require.NoError(t, os.Mkdir(filepath.Join(wapi.Dir(dir), ".rollback"), 0o755)) + + actions := RunFix(context.Background(), dir) + + assert.Equal(t, core.ActionPerformed, actionByID(t, actions)[CheckIDRollback].Status) + + _, statErr := os.Stat(filepath.Join(wapi.Dir(dir), ".rollback")) + + assert.ErrorIs(t, statErr, os.ErrNotExist, "an empty .rollback/ must still be removed") +} + +// TestRunFix_RollbackAbsent_NotNeeded pins that a healthy rollback state +// reports not-needed and writes nothing. +func TestRunFix_RollbackAbsent_NotNeeded(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + actions := RunFix(context.Background(), dir) + + assert.Equal(t, core.ActionNotNeeded, actionByID(t, actions)[CheckIDRollback].Status) +} + +// TestRunFix_LockAbsent_NotNeededAndNotCreated verifies that with no sync.lock +// file the repair reports not-needed and must NOT create the file. +func TestRunFix_LockAbsent_NotNeededAndNotCreated(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + actions := RunFix(context.Background(), dir) + + assert.Equal(t, core.ActionNotNeeded, actionByID(t, actions)[CheckIDLock].Status) + + _, err := os.Stat(lockPath(t, dir)) + + assert.ErrorIs(t, err, os.ErrNotExist, "the lock repair must not create sync.lock") +} + +// TestRunFix_LockAcquirable_VerifiedNotNeeded verifies a stale but unheld lock +// file is verified acquirable (acquired and released) and reported +// not-needed — the file itself is never removed and the lock stays acquirable +// afterwards. +func TestRunFix_LockAcquirable_VerifiedNotNeeded(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("flock semantics are unix-only; the windows path is covered by the seam tests") + } + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + actions := RunFix(context.Background(), dir) + + lockAction := actionByID(t, actions)[CheckIDLock] + + assert.Equal(t, core.ActionNotNeeded, lockAction.Status) + + // Pin the probe-path reason string: the lock was verified acquirable + // (acquired and released) with no holder detected. + assert.Contains(t, lockAction.Reason, "verified acquirable", + "the acquirable-lock probe path must carry the 'verified acquirable' reason") + + // After the verify, the lock check must report OK (acquirable), and the + // probe must still be able to acquire and release within this process. + res := (&lockCheck{projectDir: dir, goos: runtime.GOOS}).Run(context.Background()) + + assert.Equal(t, core.StatusOK, res.Status) +} + +// holdLockForTest holds sync.lock from a second open file description in the +// test process, exactly like a live second CLI process would. The returned +// release function must be called to clean up. +func holdLockForTest(t *testing.T, projectDir string) func() { + t.Helper() + + f, err := os.OpenFile(lockPath(t, projectDir), os.O_RDWR, 0o600) + + require.NoError(t, err) + + require.NoError(t, tryLockSyncLockExclusive(f)) + + return func() { + _ = unlockSyncLock(f) + _ = f.Close() + } +} + +// TestRunFix_LockHeld_SkipsAllRepairsStateUntouched verifies that when a live +// process holds the lock, EVERY repair is skipped with the sync-in-progress +// reason and no state is touched. +func TestRunFix_LockHeld_SkipsAllRepairsStateUntouched(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("flock semantics are unix-only; the windows path is covered by the seam tests") + } + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + // A repairable problem (missing manifest) that must NOT be repaired. + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + before := stateFileHashes(t, dir) + + release := holdLockForTest(t, dir) + + defer release() + + actions := RunFix(context.Background(), dir) + + requireActionsInOrder(t, actions) + + for _, a := range actions { + require.Equal(t, core.ActionSkipped, a.Status, "action %s", a.ID) + assert.Contains(t, a.Reason, "sync in progress", "action %s", a.ID) + } + + assert.Equal(t, before, stateFileHashes(t, dir), "a gated fix run must not write anything") + + _, statErr := os.Stat(filepath.Join(wapi.Dir(dir), "manifest.json")) + + assert.ErrorIs(t, statErr, os.ErrNotExist, "the manifest must NOT be rebuilt under a held lock") +} + +// TestRunFix_UninspectableLock_SkipsAllRepairs pins the conservative gate: a +// lock that cannot be inspected might hide a live sync, so no repair runs. +func TestRunFix_UninspectableLock_SkipsAllRepairs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission semantics; windows reports SKIP via the seam") + } + + if os.Geteuid() == 0 { + t.Skip("root can open unreadable files, so the uninspectable state cannot be fabricated") + } + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + require.NoError(t, os.Chmod(lockPath(t, dir), 0o000)) + + t.Cleanup(func() { + if chmodErr := os.Chmod(lockPath(t, dir), 0o600); chmodErr != nil { + t.Logf("restore lock file permissions: %v", chmodErr) + } + }) + + actions := RunFix(context.Background(), dir) + + requireActionsInOrder(t, actions) + + for _, a := range actions { + require.Equal(t, core.ActionSkipped, a.Status, "action %s", a.ID) + assert.Contains(t, a.Reason, "cannot inspect", "action %s", a.ID) + } +} + +// TestRunFix_PartialFailure_OthersStillPerformed verifies a repair that fails +// mid-write is reported skipped with the error as reason while the remaining +// repairs still run. +func TestRunFix_PartialFailure_OthersStillPerformed(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + // Force the manifest rebuild to fail: manifest.json exists as a + // DIRECTORY, so reading it corrupts and atomically writing over it fails. + require.NoError(t, os.Mkdir(filepath.Join(wapi.Dir(dir), "manifest.json"), 0o755)) + + seedRollback(t, dir, "app/main.go", "backed up contents") + + actions := RunFix(context.Background(), dir) + + requireActionsInOrder(t, actions) + + byID := actionByID(t, actions) + + rebuild := byID[CheckIDManifest] + + assert.Equal(t, core.ActionSkipped, rebuild.Status) + assert.NotEmpty(t, rebuild.Reason, "the failed repair must carry the error as its reason") + + assert.Equal(t, core.ActionPerformed, byID[CheckIDRollback].Status, "the rollback repair must still attempt") + + restored, readErr := os.ReadFile(filepath.Join(dir, "app", "main.go")) + + require.NoError(t, readErr) + + assert.Equal(t, "backed up contents", string(restored)) +} + +// TestRunFix_ManifestRebuild_WorkingTreeUntouched verifies the manifest +// rebuild only rewrites the state file; every working-tree file keeps its +// exact checksum. +func TestRunFix_ManifestRebuild_WorkingTreeUntouched(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig(testCatalogID, testVersionID))) + + writeStateFile(t, dir, "manifest.json", `{"version":1,"files":{}}`) + + working := filepath.Join(dir, "app", "main.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(working), 0o755)) + + require.NoError(t, os.WriteFile(working, []byte("user source"), 0o600)) + + before := stateFileHashes(t, dir) + + actions := RunFix(context.Background(), dir) + + assert.Equal(t, core.ActionPerformed, actionByID(t, actions)[CheckIDManifest].Status) + + after := stateFileHashes(t, dir) + + // Only manifest.json itself may change; every other path — the whole + // working tree — must be byte-identical. + for path, want := range before { + if filepath.Base(path) == "manifest.json" { + continue + } + + got, ok := after[path] + + require.True(t, ok, "file disappeared: %s", path) + assert.Equal(t, want, got, "file must be untouched by the rebuild: %s", path) + } +} + +// TestRunFix_LockFileNeverRemoved pins Release semantics at the repair level: +// verifying an acquirable lock never unlinks the file (a waiter could hold +// the open descriptor). +func TestRunFix_LockFileNeverRemoved(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("flock semantics are unix-only; the windows path is covered by the seam tests") + } + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + actions := RunFix(context.Background(), dir) + + assert.Equal(t, core.ActionNotNeeded, actionByID(t, actions)[CheckIDLock].Status) + + _, err := os.Stat(lockPath(t, dir)) + + assert.NoError(t, err, "the lock file itself must never be removed") +} + +// TestRunFix_WindowsGate_Proceeds pins the windows gate behavior: the lock +// probe SKIPs there (flock not enforced), and repairs still run — consistent +// with sync itself not being exclusive on Windows (RAPTOR-16928). +func TestRunFix_WindowsGate_Proceeds(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + // Run the repair suite the way a windows host would see it: the gate + // probe SKIPs (exercised through the injected platform seam) and the + // repairs still proceed. + actions := runFixWithGoos(context.Background(), dir, "windows") + + requireActionsInOrder(t, actions) + + assert.Equal(t, core.ActionNotNeeded, actionByID(t, actions)[CheckIDLock].Status) +} + +// TestRunFix_MultipleProblems_AllRepairedInOneRun verifies that with +// simultaneously corrupt manifest, stale .rollback/ (with a modified project +// file), and a dead sync.lock, each repair gets its own action entry; the +// post-fix state is healthy; non-rollback working-tree files are unchanged. +func TestRunFix_MultipleProblems_AllRepairedInOneRun(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("flock semantics are unix-only; the windows gate path is covered by the seam tests") + } + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + // Problem 1: corrupt manifest (truncated JSON). + writeStateFile(t, dir, "manifest.json", `{"version":1,"syncedAt":nul`) + + // Problem 2: stale .rollback/ with a backed-up file that overwrites a + // modified working-tree copy. + seedRollback(t, dir, "app/main.go", "backed up contents") + + working := filepath.Join(dir, "app", "main.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(working), 0o755)) + + require.NoError(t, os.WriteFile(working, []byte("drifted contents"), 0o600)) + + // Problem 3: a dead (unheld) sync.lock file. + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + // A non-state working-tree file that must survive untouched. + extra := filepath.Join(dir, "lib", "util.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(extra), 0o755)) + + require.NoError(t, os.WriteFile(extra, []byte("package lib"), 0o600)) + + beforeExtra, err := os.ReadFile(extra) + + require.NoError(t, err) + + actions := RunFix(context.Background(), dir) + + requireActionsInOrder(t, actions) + + byID := actionByID(t, actions) + + // Each repair gets its own action entry with a distinct status. + assert.Equal(t, core.ActionPerformed, byID[CheckIDManifest].Status, "manifest rebuild performed") + assert.Equal(t, core.ActionPerformed, byID[CheckIDRollback].Status, "rollback restore performed") + assert.Equal(t, core.ActionNotNeeded, byID[CheckIDLock].Status, "lock clear not-needed (acquirable)") + + // Post-fix: manifest parses and is an empty BASE. + m, loadErr := wapi.LoadManifest(dir) + + require.NoError(t, loadErr) + + assert.Empty(t, m.Files) + assert.Nil(t, m.SyncedVersionID) + assert.Nil(t, m.SyncedAt) + + // Post-fix: .rollback/ removed and working-tree file restored. + restored, readErr := os.ReadFile(working) + + require.NoError(t, readErr) + + assert.Equal(t, "backed up contents", string(restored)) + + _, statErr := os.Stat(filepath.Join(wapi.Dir(dir), ".rollback")) + + require.ErrorIs(t, statErr, os.ErrNotExist, ".rollback/ must be removed") + + // Non-rollback working-tree files are unchanged. + extraAfter, err := os.ReadFile(extra) + + require.NoError(t, err) + + assert.Equal(t, string(beforeExtra), string(extraAfter), "non-rollback files must be untouched") +} diff --git a/internal/workload/doctor/helpers_test.go b/internal/workload/doctor/helpers_test.go new file mode 100644 index 000000000..284fb22c6 --- /dev/null +++ b/internal/workload/doctor/helpers_test.go @@ -0,0 +1,187 @@ +// 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 doctor + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/require" +) + +const ( + // testArtifactID matches the bare-hex shape of real DataRobot artifact ids. + testArtifactID = "6a90da2ddeadbeefcafe1234" + + // testCatalogID and testVersionID are syntactically valid stand-ins for + // the catalog and catalog-version pointers recorded after a real sync. + testCatalogID = "65f1a2b3c4d5e6f7a8b9c0d1" + testVersionID = "65f1a2b3c4d5e6f7a8b9c0d2" +) + +// testHash returns a 64-char lowercase hex string for manifest FileMeta +// fixtures (mirrors the wapi package's own test helper, which cannot be +// imported across package boundaries). +func testHash(c byte) string { + return strings.Repeat(string(c), 64) +} + +// strPtr returns a pointer to s, a test helper for optional config fields. +func strPtr(s string) *string { + return &s +} + +// initStateDir creates an empty state directory at the current location so +// wapi presence succeeds without any state files inside it. +func initStateDir(t *testing.T, projectDir string) { + t.Helper() + + require.NoError(t, os.MkdirAll(wapi.Dir(projectDir), 0o755)) +} + +// validConfig builds a config that passes wapi semantic validation. Empty +// catalogID/lastSyncedVersionID leave the corresponding pointer nil. +func validConfig(catalogID, lastSyncedVersionID string) wapi.Config { + cfg := wapi.Config{ + ArtifactID: testArtifactID, + CreatedAt: time.Now().UTC(), + CLIVersion: "test-version", + } + + if catalogID != "" { + cfg.CatalogID = strPtr(catalogID) + } + + if lastSyncedVersionID != "" { + cfg.LastSyncedVersionID = strPtr(lastSyncedVersionID) + } + + return cfg +} + +// validConfigJSON renders a hand-written config.json body that passes wapi +// validation, for tests that fabricate raw file contents. +func validConfigJSON() string { + return `{"artifactId":"` + testArtifactID + `","createdAt":"2026-01-01T00:00:00Z","cliVersion":"test-version"}` +} + +// validManifest builds a manifest that passes wapi semantic validation. When +// syncedVersionID is empty the synced pointers stay nil (both-or-neither). +func validManifest(syncedVersionID string) wapi.Manifest { + m := wapi.Manifest{ + Version: wapi.ManifestVersion, + Files: map[string]wapi.FileMeta{"app/main.go": {Hash: testHash('a'), Size: 3}}, + } + + if syncedVersionID != "" { + now := time.Now().UTC() + + m.SyncedAt = &now + + m.SyncedVersionID = strPtr(syncedVersionID) + } + + return m +} + +// writeStateFile writes raw contents to a file inside the state directory +// (creating the directory first). wapi.Dir resolves to the legacy location +// when only a legacy directory exists, which legacy-path fixtures rely on. +func writeStateFile(t *testing.T, projectDir, name, contents string) { + t.Helper() + + require.NoError(t, os.MkdirAll(wapi.Dir(projectDir), 0o755)) + + require.NoError(t, os.WriteFile(filepath.Join(wapi.Dir(projectDir), name), []byte(contents), 0o600)) +} + +// stateFileHashes maps every file under projectDir to its SHA-256 hex digest. +// The read-only guarantee tests compare snapshots taken before and after a +// check run: identical maps prove zero writes and zero new files. Reads go +// through an os.Root scoped to projectDir so the walk cannot be raced into +// following a symlink outside the project. +func stateFileHashes(t *testing.T, projectDir string) map[string]string { + t.Helper() + + root, err := os.OpenRoot(projectDir) + + require.NoError(t, err) + + defer func() { + if closeErr := root.Close(); closeErr != nil { + t.Logf("close project root: %v", closeErr) + } + }() + + hashes := map[string]string{} + + err = filepath.WalkDir(projectDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + rel, err := filepath.Rel(projectDir, path) + if err != nil { + return err + } + + f, err := root.Open(rel) + if err != nil { + return err + } + + data, err := io.ReadAll(f) + + if closeErr := f.Close(); closeErr != nil { + return closeErr + } + + if err != nil { + return err + } + + sum := sha256.Sum256(data) + + hashes[path] = hex.EncodeToString(sum[:]) + + return nil + }) + + require.NoError(t, err) + + return hashes +} + +// runLocalChecks runs the full local check suite through the framework +// Runner and returns the results in the fixed check order. +func runLocalChecks(t *testing.T, projectDir string) []core.Result { + t.Helper() + + return core.NewRunner(LocalChecks(projectDir)...).Run(context.Background()) +} diff --git a/internal/workload/doctor/local.go b/internal/workload/doctor/local.go new file mode 100644 index 000000000..2e5961248 --- /dev/null +++ b/internal/workload/doctor/local.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. + +package doctor + +import ( + "errors" + "fmt" + "path/filepath" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// Stable check identifiers. They surface in reports and JSON output, so they +// must not change between releases. +const ( + CheckIDPresence = "wapi.presence" + CheckIDConfig = "wapi.config" + CheckIDManifest = "wapi.manifest" + CheckIDDivergence = "wapi.config-manifest-divergence" + CheckIDRollback = "wapi.rollback" + CheckIDLock = "wapi.lock" +) + +// Checks returns the complete doctor check suite in pinned order: the six +// local checks then the four remote checks (ten total; future extras append +// after). The remote checks share one artifact snapshot fetched through store. +// +// Each check resolves projectDir independently at Run time, so the returned +// checks stay correct even if the directory's state changes between +// construction and execution. +func Checks(projectDir string, store ArtifactGetter) []core.Check { + return append(LocalChecks(projectDir), RemoteChecks(projectDir, store)...) +} + +// LocalChecks returns the six local sync-state checks in fixed report order: +// presence, config, manifest, divergence, rollback, lock. The remote checks +// append after these. +// +// Each check resolves projectDir independently at Run time, so the returned +// checks stay correct even if the directory's state changes between +// construction and execution. +func LocalChecks(projectDir string) []core.Check { + return []core.Check{ + &presenceCheck{projectDir: projectDir}, + &configCheck{projectDir: projectDir}, + &manifestCheck{projectDir: projectDir}, + &divergenceCheck{projectDir: projectDir}, + &rollbackCheck{projectDir: projectDir}, + newLockCheck(projectDir), + } +} + +// skipIfUnlinked implements the presence-FAIL cascade: a check that needs +// linked state SKIPs with an honest "no linked state" summary rather than a +// misleading FAIL of its own. The second return value reports whether the +// caller should skip. +func skipIfUnlinked(projectDir string) (core.Result, bool) { + if wapi.Exists(projectDir) { + return core.Result{}, false + } + + return core.Result{ + Status: core.StatusSKIP, + Summary: "no linked state; nothing to check", + }, true +} + +// corruptFileResult builds a FAIL result for a missing or unreadable state +// file. The absolute path appears in both the summary and details.path for +// JSON consumers. +func corruptFileResult(summary, path, remedy string, fixable bool) core.Result { + abs := absPath(path) + + return core.Result{ + Status: core.StatusFAIL, + Summary: fmt.Sprintf("%s (%s)", summary, abs), + Remedy: remedy, + Details: map[string]string{"path": abs}, + Fixable: fixable, + } +} + +// stateErrPath extracts the file path carried by a wapi.CorruptedError, +// falling back to fallbackPath when err is not one. +func stateErrPath(err error, fallbackPath string) string { + var corruptErr *wapi.CorruptedError + + if errors.As(err, &corruptErr) { + return corruptErr.Path + } + + return fallbackPath +} + +// corruptReason returns the most specific message for a corrupted state +// file: the underlying cause of a wapi.CorruptedError (whose Error text +// already embeds the path, which corruptFileResult appends once), or the +// error itself otherwise. +func corruptReason(err error) string { + var corruptErr *wapi.CorruptedError + + if errors.As(err, &corruptErr) && corruptErr.Err != nil { + return corruptErr.Err.Error() + } + + return err.Error() +} + +// absPath converts p to its absolute form. On the (non-representable) error +// path it returns p unchanged: callers already pass an absolute project dir +// in normal wiring (the command resolves --dir with filepath.Abs). +func absPath(p string) string { + abs, err := filepath.Abs(p) + if err != nil { + return p + } + + return abs +} + +// normalizeStringPtr treats an empty string as absent, so a pointer to "" +// compares equal to nil everywhere the doctor reasons about config/manifest +// pointer fields (the "empty ≈ nil" normalization pinned for all checks). +func normalizeStringPtr(p *string) *string { + if p == nil || *p == "" { + return nil + } + + return p +} + +// ptrDisplay renders an optional pointer for human/JSON summaries: the value, +// or "null" when absent (after empty-string normalization). +func ptrDisplay(p *string) string { + if normalizeStringPtr(p) == nil { + return "null" + } + + return *p +} diff --git a/internal/workload/doctor/lock.go b/internal/workload/doctor/lock.go new file mode 100644 index 000000000..a42362ee3 --- /dev/null +++ b/internal/workload/doctor/lock.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 doctor + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/sync" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// lockCheck probes the project's sync.lock NON-CREATINGLY: it opens the file +// WITHOUT O_CREATE and attempts a non-blocking exclusive advisory lock. +// +// It deliberately does NOT use sync.AcquireSyncLock, which creates the file +// (O_CREATE) and never removes it — using it for diagnosis would leave a new +// file behind and violate the read-only guarantee. +type lockCheck struct { + projectDir string + + // goos is the injected platform seam: production constructs the check + // with runtime.GOOS; tests inject "windows" to exercise the SKIP path + // (flock is not enforced there, per RAPTOR-16928) on any host. + goos string +} + +// newLockCheck builds the lock check with the real host platform. +func newLockCheck(projectDir string) *lockCheck { + return newLockCheckWithGoos(projectDir, runtime.GOOS) +} + +// newLockCheckWithGoos builds the lock check with an injected platform, so +// the windows SKIP path (flock not enforced, per RAPTOR-16928) stays +// unit-testable on any host. +func newLockCheckWithGoos(projectDir, goos string) *lockCheck { + return &lockCheck{projectDir: projectDir, goos: goos} +} + +func (c *lockCheck) ID() string { + return CheckIDLock +} + +func (c *lockCheck) Name() string { + return "Sync lock" +} + +// Run classifies the lock file into four outcomes: +// - absent (ENOENT) -> OK, nothing held, and the file is NOT created +// - open + acquire -> OK, release immediately (release happens +// inside Run, not at process exit) +// - open + flock fails -> FAIL, held by a live process +// - open fails (perm / I/O) -> WARN "cannot inspect", never misreported +// as "held by another process" +// +// On Windows (injected seam) it reports SKIP without touching the filesystem. +func (c *lockCheck) Run(_ context.Context) core.Result { + if c.goos == "windows" { + return core.Result{ + Status: core.StatusSKIP, + Summary: "lock not enforced on this platform", + } + } + + if res, skip := skipIfUnlinked(c.projectDir); skip { + return res + } + + path := filepath.Join(wapi.Dir(c.projectDir), sync.LockFileName) + + // No O_CREATE: a read-only diagnosis must never leave a lock file behind. + f, err := os.OpenFile(path, os.O_RDWR, 0o600) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return core.Result{ + Status: core.StatusOK, + Summary: "no sync lock file; nothing held", + } + } + + return core.Result{ + Status: core.StatusWARN, + Summary: fmt.Sprintf("cannot inspect sync lock: %s", err), + Remedy: RemedyLockInspect, + } + } + + if lockErr := tryLockSyncLockExclusive(f); lockErr != nil { + // Release nothing: we never owned the lock. Close errors here are + // non-actionable (read-only descriptor teardown). + _ = f.Close() + + return core.Result{ + Status: core.StatusFAIL, + Summary: "sync lock held by a live process", + Remedy: RemedyLockHeld, + } + } + + if releaseErr := releaseSyncLock(f); releaseErr != nil { + return core.Result{ + Status: core.StatusWARN, + Summary: fmt.Sprintf("cannot inspect sync lock: %s", releaseErr), + Remedy: RemedyLockInspect, + } + } + + return core.Result{ + Status: core.StatusOK, + Summary: "sync lock is acquirable (no live holder)", + } +} + +// releaseSyncLock unlocks and closes the probe's descriptor. It must only be +// called after a successful acquire. +func releaseSyncLock(f *os.File) error { + if err := unlockSyncLock(f); err != nil { + return fmt.Errorf("unlock sync lock: %w", err) + } + + if err := f.Close(); err != nil { + return fmt.Errorf("close sync lock: %w", err) + } + + return nil +} diff --git a/internal/workload/doctor/lock_test.go b/internal/workload/doctor/lock_test.go new file mode 100644 index 000000000..8576c02cf --- /dev/null +++ b/internal/workload/doctor/lock_test.go @@ -0,0 +1,175 @@ +// 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 doctor + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/sync" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func lockPath(t *testing.T, projectDir string) string { + t.Helper() + + return filepath.Join(wapi.Dir(projectDir), sync.LockFileName) +} + +func TestLockCheck_OK_AbsentFileNotCreated(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + res := (&lockCheck{projectDir: dir, goos: runtime.GOOS}).Run(context.Background()) + + assert.Equal(t, core.StatusOK, res.Status) + + // The probe must be non-creating: a read-only diagnosis never leaves a + // sync.lock behind. + _, err := os.Stat(lockPath(t, dir)) + + assert.ErrorIs(t, err, fs.ErrNotExist, "probe must not create sync.lock") +} + +func TestLockCheck_OK_AcquirableAndReleasedWithinRun(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("flock semantics are unix-only; the windows path is covered by the seam test") + } + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + res := (&lockCheck{projectDir: dir, goos: runtime.GOOS}).Run(context.Background()) + + assert.Equal(t, core.StatusOK, res.Status) + + // Prove the check released the lock before Run returned: a fresh open + + // non-blocking exclusive lock in the SAME process must succeed now. + f, err := os.OpenFile(lockPath(t, dir), os.O_RDWR, 0o600) + + require.NoError(t, err) + + defer func() { + if closeErr := f.Close(); closeErr != nil { + t.Logf("close lock probe file: %v", closeErr) + } + }() + + require.NoError(t, tryLockSyncLockExclusive(f), "lock must be releasable within Run") + + require.NoError(t, unlockSyncLock(f)) +} + +func TestLockCheck_FAIL_HeldByLiveHolder(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("flock semantics are unix-only; the windows path is covered by the seam test") + } + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + // Hold the lock from a second open file description in this process — + // flock contends between two fds of the same file, exactly like a live + // second CLI process would. + holder, err := os.OpenFile(lockPath(t, dir), os.O_RDWR, 0o600) + + require.NoError(t, err) + + require.NoError(t, tryLockSyncLockExclusive(holder)) + + res := (&lockCheck{projectDir: dir, goos: runtime.GOOS}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + assert.Contains(t, res.Summary, "held") + + assert.Equal(t, RemedyLockHeld, res.Remedy) + + // Release and confirm the check flips to OK. + require.NoError(t, unlockSyncLock(holder)) + + require.NoError(t, holder.Close()) + + res = (&lockCheck{projectDir: dir, goos: runtime.GOOS}).Run(context.Background()) + + assert.Equal(t, core.StatusOK, res.Status) +} + +func TestLockCheck_WARN_CannotInspect(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix permission semantics; windows reports SKIP via the seam") + } + + if os.Geteuid() == 0 { + t.Skip("root can open unreadable files, so the WARN path cannot be fabricated") + } + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + require.NoError(t, os.Chmod(lockPath(t, dir), 0o000)) + + t.Cleanup(func() { + if chmodErr := os.Chmod(lockPath(t, dir), 0o600); chmodErr != nil { + t.Logf("restore lock file permissions: %v", chmodErr) + } + }) + + res := (&lockCheck{projectDir: dir, goos: runtime.GOOS}).Run(context.Background()) + + assert.Equal(t, core.StatusWARN, res.Status) + + // An inspection failure must NEVER be misreported as a held lock. + assert.Contains(t, res.Summary, "cannot inspect") + + assert.NotContains(t, res.Summary, "held") + + assert.Equal(t, RemedyLockInspect, res.Remedy) +} + +func TestLockCheck_SKIP_WindowsSeam(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + // Even with a lock file present, the injected windows platform reports + // SKIP and never reaches the flock probe. + res := (&lockCheck{projectDir: dir, goos: "windows"}).Run(context.Background()) + + assert.Equal(t, core.StatusSKIP, res.Status) + + assert.Contains(t, res.Summary, "lock not enforced on this platform") + + assert.Empty(t, res.Remedy) +} diff --git a/internal/workload/doctor/lockprobe_unix.go b/internal/workload/doctor/lockprobe_unix.go new file mode 100644 index 000000000..f2b0c8124 --- /dev/null +++ b/internal/workload/doctor/lockprobe_unix.go @@ -0,0 +1,34 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package doctor + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// tryLockSyncLockExclusive attempts a non-blocking exclusive advisory lock +// on f. It fails with EWOULDBLOCK when another live process holds the lock. +func tryLockSyncLockExclusive(f *os.File) error { + return unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) //nolint:gosec // uintptr and int are same size on supported platforms +} + +// unlockSyncLock releases the advisory lock held on f. +func unlockSyncLock(f *os.File) error { + return unix.Flock(int(f.Fd()), unix.LOCK_UN) //nolint:gosec // uintptr and int are same size on supported platforms +} diff --git a/internal/workload/doctor/lockprobe_windows.go b/internal/workload/doctor/lockprobe_windows.go new file mode 100644 index 000000000..711df82e9 --- /dev/null +++ b/internal/workload/doctor/lockprobe_windows.go @@ -0,0 +1,32 @@ +// 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. + +//go:build windows + +package doctor + +import "os" + +// tryLockSyncLockExclusive on Windows is a no-op: the lock check reports +// SKIP through the platform seam before ever reaching this path (real +// LockFileEx support is tracked in RAPTOR-16928). It exists so the package +// compiles on GOOS=windows. +func tryLockSyncLockExclusive(_ *os.File) error { + return nil +} + +// unlockSyncLock mirrors the unix release for the same compile-only reason. +func unlockSyncLock(_ *os.File) error { + return nil +} diff --git a/internal/workload/doctor/manifest.go b/internal/workload/doctor/manifest.go new file mode 100644 index 000000000..acf0dd696 --- /dev/null +++ b/internal/workload/doctor/manifest.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 doctor + +import ( + "context" + "errors" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// manifestCheck verifies that manifest.json (the BASE snapshot) parses and +// passes wapi's semantic validation (version, both-or-neither sync state, +// per-file metadata). +type manifestCheck struct { + projectDir string +} + +func (c *manifestCheck) ID() string { + return CheckIDManifest +} + +func (c *manifestCheck) Name() string { + return "Manifest file" +} + +// Run loads the manifest read-only. Unlike the divergence check it does not +// depend on config.json, so it still runs when the config is broken. A +// missing or corrupt manifest is a FAIL carrying the absolute path in +// details.path, and is repairable by `--fix` (empty-BASE rebuild). +func (c *manifestCheck) Run(_ context.Context) core.Result { + if res, skip := skipIfUnlinked(c.projectDir); skip { + return res + } + + _, err := wapi.LoadManifest(c.projectDir) + + switch { + case err == nil: + return core.Result{ + Status: core.StatusOK, + Summary: "manifest.json is valid", + } + case errors.Is(err, wapi.ErrNotInitialized): + return corruptFileResult("manifest.json is missing", wapi.ManifestPath(c.projectDir), RemedyManifest, true) + default: + return corruptFileResult( + "manifest.json is corrupt: "+corruptReason(err), + stateErrPath(err, wapi.ManifestPath(c.projectDir)), + RemedyManifest, + true, + ) + } +} diff --git a/internal/workload/doctor/manifest_test.go b/internal/workload/doctor/manifest_test.go new file mode 100644 index 000000000..0d5897b54 --- /dev/null +++ b/internal/workload/doctor/manifest_test.go @@ -0,0 +1,145 @@ +// 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 doctor + +import ( + "context" + "path/filepath" + "testing" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManifestCheck_OK(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveManifest(dir, validManifest(testVersionID))) + + res := (&manifestCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusOK, res.Status) + + assert.Empty(t, res.Remedy) +} + +func TestManifestCheck_FAIL_Missing(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + res := (&manifestCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + assert.Contains(t, res.Summary, "missing") + + assert.Equal(t, RemedyManifest, res.Remedy) + + assert.True(t, res.Fixable) + + wantPath, err := filepath.Abs(wapi.ManifestPath(dir)) + + require.NoError(t, err) + + assert.Equal(t, wantPath, res.Details["path"]) +} + +func TestManifestCheck_FAIL_CorruptShowsAbsolutePath(t *testing.T) { + dir := t.TempDir() + + writeStateFile(t, dir, "manifest.json", `{"version":1,`) + + res := (&manifestCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + wantPath, err := filepath.Abs(wapi.ManifestPath(dir)) + + require.NoError(t, err) + + assert.Equal(t, wantPath, res.Details["path"]) + + assert.Contains(t, res.Summary, wantPath) +} + +func TestManifestCheck_FAIL_InvalidSemantics(t *testing.T) { + t.Run("wrong version", func(t *testing.T) { + dir := t.TempDir() + + writeStateFile(t, dir, "manifest.json", `{"version":2,"syncedAt":null,"syncedVersionId":null,"files":{}}`) + + res := (&manifestCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + wantPath, err := filepath.Abs(wapi.ManifestPath(dir)) + + require.NoError(t, err) + + assert.Equal(t, wantPath, res.Details["path"]) + }) + + t.Run("syncedVersionId without syncedAt", func(t *testing.T) { + dir := t.TempDir() + + writeStateFile(t, dir, "manifest.json", + `{"version":1,"syncedAt":null,"syncedVersionId":"`+testVersionID+`","files":{}}`) + + res := (&manifestCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + }) + + t.Run("syncedAt without syncedVersionId", func(t *testing.T) { + dir := t.TempDir() + + writeStateFile(t, dir, "manifest.json", + `{"version":1,"syncedAt":"2026-01-01T00:00:00Z","syncedVersionId":null,"files":{}}`) + + res := (&manifestCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + }) + + t.Run("invalid FileMeta hash", func(t *testing.T) { + dir := t.TempDir() + + writeStateFile(t, dir, "manifest.json", + `{"version":1,"syncedAt":null,"syncedVersionId":null,"files":{"app/main.go":{"hash":"nothex","size":3}}}`) + + res := (&manifestCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + wantPath, err := filepath.Abs(wapi.ManifestPath(dir)) + + require.NoError(t, err) + + assert.Equal(t, wantPath, res.Details["path"]) + }) +} + +func TestManifestCheck_SKIP_NotLinked(t *testing.T) { + res := (&manifestCheck{projectDir: t.TempDir()}).Run(context.Background()) + + assert.Equal(t, core.StatusSKIP, res.Status) + + assert.Contains(t, res.Summary, "no linked state") +} diff --git a/internal/workload/doctor/presence.go b/internal/workload/doctor/presence.go new file mode 100644 index 000000000..8f9e7d4e2 --- /dev/null +++ b/internal/workload/doctor/presence.go @@ -0,0 +1,55 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doctor + +import ( + "context" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// presenceCheck reports whether the project directory is linked to a remote +// artifact, i.e. a state directory exists at the current location or the +// legacy one. +type presenceCheck struct { + projectDir string +} + +func (c *presenceCheck) ID() string { + return CheckIDPresence +} + +func (c *presenceCheck) Name() string { + return "State directory" +} + +// Run stats the state directory through wapi.Exists, which resolves the +// current location first and falls back to legacy .wapi/. A regular file at +// the state path counts as not linked. +func (c *presenceCheck) Run(_ context.Context) core.Result { + if wapi.Exists(c.projectDir) { + return core.Result{ + Status: core.StatusOK, + Summary: "project is linked (state directory found)", + } + } + + return core.Result{ + Status: core.StatusFAIL, + Summary: "project is not linked (no state directory found)", + Remedy: RemedyPresence, + } +} diff --git a/internal/workload/doctor/presence_test.go b/internal/workload/doctor/presence_test.go new file mode 100644 index 000000000..a4eeaeb7a --- /dev/null +++ b/internal/workload/doctor/presence_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doctor + +import ( + "context" + "os" + "path/filepath" + "testing" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPresenceCheck_OK_CurrentLocation(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + res := (&presenceCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusOK, res.Status) + + assert.Contains(t, res.Summary, "linked") + + assert.Empty(t, res.Remedy) +} + +func TestPresenceCheck_OK_LegacyLocation(t *testing.T) { + dir := t.TempDir() + + // Legacy-only project: no .datarobot/workload, just .wapi. + require.NoError(t, os.MkdirAll(filepath.Join(dir, wapi.LegacyDirName), 0o755)) + + res := (&presenceCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusOK, res.Status) + + assert.Contains(t, res.Summary, "linked") +} + +func TestPresenceCheck_FAIL_NotLinked(t *testing.T) { + res := (&presenceCheck{projectDir: t.TempDir()}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + assert.Equal(t, RemedyPresence, res.Remedy) + + assert.Contains(t, res.Summary, "not linked") +} + +func TestPresenceCheck_FAIL_StateDirPathIsFile(t *testing.T) { + dir := t.TempDir() + + // A regular file where the state dir belongs must not be treated as + // linked (and must not crash the check). + statePath := filepath.Join(dir, wapi.RootDirName) + + require.NoError(t, os.MkdirAll(filepath.Dir(statePath), 0o755)) + + require.NoError(t, os.WriteFile(statePath, []byte("not a directory"), 0o600)) + + res := (&presenceCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) +} diff --git a/internal/workload/doctor/relink.go b/internal/workload/doctor/relink.go new file mode 100644 index 000000000..d0d397655 --- /dev/null +++ b/internal/workload/doctor/relink.go @@ -0,0 +1,307 @@ +// 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 doctor + +import ( + "context" + "errors" + "fmt" + "runtime" + "time" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/internal/workload/manifest" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// RelinkActionID is the stable identifier for the relink action in the +// actions array. It surfaces in JSON output and must not change between +// releases. +const RelinkActionID = "relink" + +// RelinkWarning is the canonical warning text shown before a relink. It is +// reused by the command layer for both interactive and non-interactive paths +// and always routed to stderr (JSON purity on stdout). +const RelinkWarning = "Relink repoints the project at a new artifact and resets the sync baseline; the next sync reconciles against the new artifact." + +// Sentinel errors for the relink operation. The command layer distinguishes +// ErrRelinkNotLinked and ErrRelinkAPIUnreachable (which print a message to +// stderr) from ErrRelinkAbort (which forces exit 1 without a separate +// message — the actions array already describes the reason). +var ( + // ErrRelinkNotLinked is returned when the project has no linked state. + // The command layer prints this to stderr; the checks also show + // wapi.presence FAIL with the init remedy. + ErrRelinkNotLinked = errors.New("project is not linked; run 'dr artifact code init ' first") + + // ErrRelinkAPIUnreachable is returned when the API cannot be reached or + // the credentials are unusable. Relink hard-requires the API (it must + // fetch the target artifact to validate it). + ErrRelinkAPIUnreachable = errors.New("cannot reach the DataRobot API; relink requires the API — run 'dr auth login' or fix network connectivity") + + // ErrRelinkAbort is a sentinel for abort cases where the actions array + // already describes the reason (404, locked, wrong type, lock held, + // declined). The command layer forces exit 1 without printing a + // separate error message. + ErrRelinkAbort = errors.New("relink aborted") +) + +// RelinkConfirmFunc is called with the warning text after all safety gates +// pass. Returning true proceeds with the relink; returning false aborts with +// state untouched. The command layer provides the implementation: +// interactive TTY shows a [y/N] prompt (empty Enter declines); non-interactive +// (--yes or non-TTY) prints the warning and proceeds. +type RelinkConfirmFunc func(warning string) bool + +// RelinkOptions configures a relink operation. +type RelinkOptions struct { + // ProjectDir is the resolved absolute project directory. + ProjectDir string + + // NewArtifactID is the bare-hex id of the artifact to relink to. + NewArtifactID string + + // Store is the artifact-store seam used to fetch the target artifact. + // Production uses workload.GetArtifact; tests inject a fake. + Store ArtifactGetter + + // Confirm is called after all safety gates pass. The command layer + // provides the interactive or non-interactive implementation. + Confirm RelinkConfirmFunc + + // Goos is the injected platform seam for the lock probe. Production + // uses runtime.GOOS; tests inject "windows" to exercise the SKIP path. + Goos string + + // Now returns the current time for the history entry timestamp. + // Production uses time.Now; tests inject a fixed clock. + Now func() time.Time +} + +// RunRelink executes the `doctor --relink ` operation for +// projectDir: an in-place repoint with a fresh-BASE reset. +// +// Safety gates (every abort leaves state byte-identical): +// 1. Lock probe (non-creating) — held by a live process → abort. +// 2. Not-linked project → error pointing to init. +// 3. Fetch new artifact — unreachable/unauthenticated → error abort. +// 4. Target 404 → abort. +// 5. Target locked → abort (cannot sync to a locked artifact). +// 6. Target Artifact.Type != "service" → abort (cross-type lineage refused). +// +// After all gates pass, the confirm function is called. On confirmation: +// - Config rewritten (artifactId=new, catalogId=new codeRef.CatalogID +// normalized empty→nil, lastSyncedVersionId=nil). +// - Manifest reset to empty BASE (Files={}, synced fields nil). +// - History.log appended {op:relink, from, to, ts}. +// - Working tree untouched. Zero server writes. +// +// The same-id relink (target == currently linked) is allowed, warned, and +// resets BASE. +// +// The returned actions describe the relink; the returned error is non-nil for +// every abort case (the command layer forces exit 1). +func RunRelink(ctx context.Context, opts RelinkOptions) ([]core.Action, error) { + if opts.Goos == "" { + opts.Goos = runtime.GOOS + } + + if opts.Now == nil { + opts.Now = time.Now + } + + // Gate 1: lock probe (non-creating, same as --fix's global safety gate). + if actions, abort := relinkLockGate(ctx, opts); abort != nil { + return actions, abort + } + + // Gate 2: not-linked project → error pointing to init, before any network fetch. + oldCfg, err := relinkLoadOldConfig(opts.ProjectDir) + if err != nil { + return nil, err + } + + // Gate 3-5: fetch the target artifact and validate it (404, locked, type). + art, fetchActions, err := relinkFetchAndValidate(opts) + if err != nil { + return fetchActions, err + } + + // Same-id relink → allowed, warned, BASE reset. + warning := relinkWarning(oldCfg.ArtifactID, opts.NewArtifactID) + + // Confirm prompt (defaults to No; empty Enter declines). A nil Confirm + // function is treated as a decline so an internal caller that forgets to + // set it cannot accidentally proceed with a destructive operation. + confirm := opts.Confirm + + if confirm == nil { + return relinkSkipped("no confirm function provided; relink declined as a safety default"), ErrRelinkAbort + } + + if !confirm(warning) { + return relinkSkipped("declined by user"), ErrRelinkAbort + } + + // All gates passed and the user confirmed. Perform the writes. + return relinkWrite(opts, oldCfg, art) +} + +// relinkLockGate probes the sync lock (non-creating). Returns (nil, nil) when +// the gate is open; (skippedActions, ErrRelinkAbort) when a live process holds +// the lock or it cannot be inspected. +func relinkLockGate(ctx context.Context, opts RelinkOptions) ([]core.Action, error) { + switch gate := newLockCheckWithGoos(opts.ProjectDir, opts.Goos).Run(ctx); gate.Status { + case core.StatusFAIL: + return relinkSkipped(ReasonSyncInProgress), ErrRelinkAbort + case core.StatusWARN: + return relinkSkipped(ReasonLockUninspectable), ErrRelinkAbort + case core.StatusOK, core.StatusSKIP: + // OK: nothing held. SKIP: Windows (flock not enforced). Both let the + // relink through. + return nil, nil + } + + // Unreachable: Status is an exhaustive enum. + return nil, nil +} + +// relinkLoadOldConfig loads the current config to get the old artifact id. +// Returns ErrRelinkNotLinked when the project has no linked state. +func relinkLoadOldConfig(projectDir string) (wapi.Config, error) { + if !wapi.Exists(projectDir) { + return wapi.Config{}, ErrRelinkNotLinked + } + + cfg, err := wapi.LoadConfig(projectDir) + if err != nil { + if errors.Is(err, wapi.ErrNotInitialized) { + return wapi.Config{}, ErrRelinkNotLinked + } + + return wapi.Config{}, fmt.Errorf( + "cannot read linked state: %w (run 'dr artifact code doctor --fix' to repair config.json)", err) + } + + return cfg, nil +} + +// relinkFetchAndValidate fetches the target artifact and runs the 404, locked, +// and type gates. Returns (artifact, nil, nil) on success; +// (nil, skippedActions, ErrRelinkAbort) for 404/locked/wrong-type; +// (nil, nil, ErrRelinkAPIUnreachable) for any other fetch failure. +func relinkFetchAndValidate(opts RelinkOptions) (*workload.Artifact, []core.Action, error) { + art, err := opts.Store.Get(opts.NewArtifactID) + if err != nil { + if isNotFound(err) { + return nil, relinkSkipped(fmt.Sprintf( + "target artifact %s not found (deleted?)", opts.NewArtifactID, + )), ErrRelinkAbort + } + + return nil, nil, fmt.Errorf("%w: %w", ErrRelinkAPIUnreachable, err) + } + + if art.IsLocked() { + return nil, relinkSkipped(fmt.Sprintf( + "target artifact %s is locked; cannot sync to a locked artifact — use a draft or relink to one", + opts.NewArtifactID, + )), ErrRelinkAbort + } + + if !manifest.SameArtifactType(manifest.ArtifactTypeOrDefault(art.Type), manifest.TypeService) { + return nil, relinkSkipped(fmt.Sprintf( + "target artifact %s has type %q, not %q; cross-type lineage is refused", + opts.NewArtifactID, art.Type, manifest.TypeService, + )), ErrRelinkAbort + } + + return art, nil, nil +} + +// relinkWarning builds the warning text, adding a same-id note when the +// target is the same as the currently linked artifact. +func relinkWarning(oldID, newID string) string { + warning := RelinkWarning + + if oldID == newID { + warning = fmt.Sprintf("%s\nNote: re-linking to the same artifact (%s); the sync baseline will be reset.", warning, newID) + } + + return warning +} + +// relinkWrite performs the config/manifest/history writes after all gates pass +// and the user confirms. Returns a performed action on success; a skipped +// action with ErrRelinkAbort on any write failure. +// +// Mid-write non-atomicity: the three writes (SaveConfig, SaveManifest, +// AppendHistory) are not atomic across each other. State is untouched until +// the first write (SaveConfig); if SaveConfig succeeds but a later write +// fails, the project is left in a partially-relinked state (config repointed +// but manifest/history stale). Recovery is 'dr artifact code doctor --fix', +// which rebuilds the manifest from the now-correct config and re-runs the +// checks. Each write uses wapi's atomic-write (write-temp-then-rename) so an +// individual file is never left half-written, but the sequence as a whole is +// not transactional. +func relinkWrite(opts RelinkOptions, oldCfg wapi.Config, art *workload.Artifact) ([]core.Action, error) { + newCfg := wapi.Config{ + ArtifactID: opts.NewArtifactID, + CatalogID: codeRefCatalog(art), // empty→nil normalization + LastSyncedVersionID: nil, // fresh BASE + CreatedAt: oldCfg.CreatedAt, // preserve original creation time + CLIVersion: oldCfg.CLIVersion, // preserve CLI version + } + + if err := wapi.SaveConfig(opts.ProjectDir, newCfg); err != nil { + return relinkSkipped(fmt.Sprintf("write config: %v", err)), ErrRelinkAbort + } + + newManifest := wapi.Manifest{ + Version: wapi.ManifestVersion, + Files: map[string]wapi.FileMeta{}, + } + + if err := wapi.SaveManifest(opts.ProjectDir, newManifest); err != nil { + return relinkSkipped(fmt.Sprintf("write manifest: %v", err)), ErrRelinkAbort + } + + historyEntry := wapi.HistoryEntry{ + "op": "relink", + "from": oldCfg.ArtifactID, + "to": opts.NewArtifactID, + "ts": opts.Now().UTC().Format(time.RFC3339), + } + + if err := wapi.AppendHistory(opts.ProjectDir, historyEntry); err != nil { + return relinkSkipped(fmt.Sprintf("append history: %v", err)), ErrRelinkAbort + } + + return []core.Action{{ + ID: RelinkActionID, + Status: core.ActionPerformed, + Reason: fmt.Sprintf("repointed from %s to %s; sync baseline reset", oldCfg.ArtifactID, opts.NewArtifactID), + }}, nil +} + +// relinkSkipped builds a single skipped action for an abort case. +func relinkSkipped(reason string) []core.Action { + return []core.Action{{ + ID: RelinkActionID, + Status: core.ActionSkipped, + Reason: reason, + }} +} diff --git a/internal/workload/doctor/relink_test.go b/internal/workload/doctor/relink_test.go new file mode 100644 index 000000000..4514d01a2 --- /dev/null +++ b/internal/workload/doctor/relink_test.go @@ -0,0 +1,716 @@ +// 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 doctor + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newArtifactID is a second bare-hex id distinct from testArtifactID, used as +// the relink target in happy-path tests. +const newArtifactID = "6a90da2ddeadbeefcafe5678" + +// newCatalogID is a second catalog id distinct from testCatalogID. +const newCatalogID = "65f1a2b3c4d5e6f7a8b9c0d3" + +// fixedTime is a deterministic clock for history-entry timestamps. +var fixedTime = time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC) + +// alwaysConfirm is a RelinkConfirmFunc that always proceeds. +func alwaysConfirm(_ string) bool { return true } + +// neverConfirm is a RelinkConfirmFunc that always declines. +func neverConfirm(_ string) bool { return false } + +// fakeStore returns an ArtifactGetter that always returns the given artifact. +func fakeStore(art *workload.Artifact) ArtifactGetter { + return ArtifactGetterFunc(func(string) (*workload.Artifact, error) { + return art, nil + }) +} + +// errorStore returns an ArtifactGetter that always returns the given error. +func errorStore(err error) ArtifactGetter { + return ArtifactGetterFunc(func(string) (*workload.Artifact, error) { + return nil, err + }) +} + +// makeArtifact builds an artifact fixture with the given id, status, and +// optional codeRef planted on the primary container. +func makeArtifact(id, status string, codeRef *workload.DatarobotCodeRef) *workload.Artifact { + art := &workload.Artifact{ + ID: id, + Name: "doctor-test", + Status: status, + } + + if codeRef == nil { + return art + } + + primary := true + + art.Spec.ContainerGroups = []workload.ContainerGroup{ + { + Containers: []workload.Container{ + { + Primary: &primary, + ImageBuildConfig: &workload.ImageBuildConfig{ + CodeRef: &workload.CodeRef{Datarobot: codeRef}, + }, + }, + }, + }, + } + + return art +} + +// fakeDraftArtifact returns a draft service artifact with an optional codeRef. +func fakeDraftArtifact(id string, codeRef *workload.DatarobotCodeRef) *workload.Artifact { + return makeArtifact(id, "DRAFT", codeRef) +} + +// relinkOpts builds a RelinkOptions with sensible defaults for tests. +func relinkOpts(dir, newID string, store ArtifactGetter, confirm RelinkConfirmFunc) RelinkOptions { + return RelinkOptions{ + ProjectDir: dir, + NewArtifactID: newID, + Store: store, + Confirm: confirm, + Goos: runtime.GOOS, + Now: func() time.Time { return fixedTime }, + } +} + +// linkedProject creates a temp dir with a valid linked state (config + manifest +// + history) pointing at testArtifactID. The catalogId and lastSyncedVersionId +// are populated to simulate a post-sync state. +func linkedProject(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig(testCatalogID, testVersionID))) + require.NoError(t, wapi.SaveManifest(dir, validManifest(testVersionID))) + + // Seed a history.log with an init entry. + require.NoError(t, wapi.AppendHistory(dir, wapi.HistoryEntry{ + "op": "init", "ts": "2026-01-01T00:00:00Z", + })) + + return dir +} + +// linkedDraftProject creates a temp dir with a valid never-synced linked state +// (no catalog pointers, empty manifest). +func linkedDraftProject(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + require.NoError(t, wapi.SaveManifest(dir, wapi.Manifest{ + Version: wapi.ManifestVersion, + Files: map[string]wapi.FileMeta{}, + })) + + return dir +} + +// TestRunRelink_HappyPath_RepointsWithFreshBase verifies relink from an old +// artifact to a new live draft artifact repoints config, resets manifest to +// empty BASE, and appends a relink history entry. +func TestRunRelink_HappyPath_RepointsWithFreshBase(t *testing.T) { + dir := linkedProject(t) + + // Place a working-tree file that must survive untouched. + working := filepath.Join(dir, "app", "main.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(working), 0o755)) + + require.NoError(t, os.WriteFile(working, []byte("user source"), 0o600)) + + before := stateFileHashes(t, dir) + + target := fakeDraftArtifact(newArtifactID, &workload.DatarobotCodeRef{ + CatalogID: newCatalogID, + CatalogVersionID: "65f1a2b3c4d5e6f7a8b9c0d4", + }) + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.NoError(t, err) + + require.Len(t, actions, 1) + + assert.Equal(t, RelinkActionID, actions[0].ID) + assert.Equal(t, core.ActionPerformed, actions[0].Status) + assert.Contains(t, actions[0].Reason, testArtifactID) + assert.Contains(t, actions[0].Reason, newArtifactID) + + // Config: artifactId=new, catalogId=new codeRef.CatalogID, lsv=nil. + cfg, err := wapi.LoadConfig(dir) + + require.NoError(t, err) + + assert.Equal(t, newArtifactID, cfg.ArtifactID) + + require.NotNil(t, cfg.CatalogID) + + assert.Equal(t, newCatalogID, *cfg.CatalogID) + + assert.Nil(t, cfg.LastSyncedVersionID, "lastSyncedVersionId must be nil (fresh BASE)") + + // Manifest: empty BASE (files={}, synced fields nil). + m, err := wapi.LoadManifest(dir) + + require.NoError(t, err) + + assert.Empty(t, m.Files) + assert.Nil(t, m.SyncedVersionID) + assert.Nil(t, m.SyncedAt) + assert.Equal(t, wapi.ManifestVersion, m.Version) + + // History: last line is {op:relink, from, to, ts}. + history, err := os.ReadFile(filepath.Join(wapi.Dir(dir), "history.log")) + + require.NoError(t, err) + + lines := splitLines(string(history)) + + last := lines[len(lines)-1] + + assert.Contains(t, last, `"op":"relink"`) + assert.Contains(t, last, `"from":"`+testArtifactID+`"`) + assert.Contains(t, last, `"to":"`+newArtifactID+`"`) + assert.Contains(t, last, `"ts":"`+fixedTime.Format(time.RFC3339)+`"`) + + // Working tree untouched: the project file is byte-identical. + after := stateFileHashes(t, dir) + + workingAfter, ok := after[working] + + require.True(t, ok) + + assert.Equal(t, before[working], workingAfter, "working-tree file must be untouched") +} + +// TestRunRelink_FreshInit_CatalogIdFromTargetOrNil verifies relink from a +// fresh init (no prior sync) to a new artifact with no codeRef leaves +// catalogId nil and lsv nil. +func TestRunRelink_FreshInit_CatalogIdFromTargetOrNil(t *testing.T) { + dir := linkedDraftProject(t) + + target := fakeDraftArtifact(newArtifactID, nil) // no codeRef + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.NoError(t, err) + + assert.Equal(t, core.ActionPerformed, actions[0].Status) + + cfg, err := wapi.LoadConfig(dir) + + require.NoError(t, err) + + assert.Equal(t, newArtifactID, cfg.ArtifactID) + assert.Nil(t, cfg.CatalogID, "no codeRef means catalogId is nil") + assert.Nil(t, cfg.LastSyncedVersionID) +} + +// TestRunRelink_EmptyCodeRef_NormalizedToNil covers the empty-vs-nil +// normalization: a codeRef with empty CatalogID field normalizes to nil. +func TestRunRelink_EmptyCodeRef_NormalizedToNil(t *testing.T) { + dir := linkedDraftProject(t) + + // codeRef with empty CatalogID (not nil, but empty string). + target := fakeDraftArtifact(newArtifactID, &workload.DatarobotCodeRef{ + CatalogID: "", + CatalogVersionID: "", + }) + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.NoError(t, err) + + assert.Equal(t, core.ActionPerformed, actions[0].Status) + + cfg, err := wapi.LoadConfig(dir) + + require.NoError(t, err) + + assert.Nil(t, cfg.CatalogID, "empty codeRef.CatalogID must normalize to nil") +} + +// TestRunRelink_PopulatedBaseWiped verifies a populated manifest (files + +// synced pointers) is wiped to empty BASE. +func TestRunRelink_PopulatedBaseWiped(t *testing.T) { + dir := linkedProject(t) + + // Manifest has files and synced pointers. + mBefore, err := wapi.LoadManifest(dir) + + require.NoError(t, err) + + assert.NotEmpty(t, mBefore.Files) + require.NotNil(t, mBefore.SyncedVersionID) + + target := fakeDraftArtifact(newArtifactID, &workload.DatarobotCodeRef{ + CatalogID: newCatalogID, + }) + + _, err = RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.NoError(t, err) + + mAfter, err := wapi.LoadManifest(dir) + + require.NoError(t, err) + + assert.Empty(t, mAfter.Files, "files must be wiped") + assert.Nil(t, mAfter.SyncedVersionID, "syncedVersionId must be nil") + assert.Nil(t, mAfter.SyncedAt, "syncedAt must be nil") +} + +// TestRunRelink_SameID_AllowedWarnedBaseReset verifies relinking to the same +// artifact id is allowed, warned, and resets BASE. +func TestRunRelink_SameID_AllowedWarnedBaseReset(t *testing.T) { + dir := linkedProject(t) + + warningText := "" + + captureConfirm := func(warning string) bool { + warningText = warning + + return true + } + + target := fakeDraftArtifact(testArtifactID, &workload.DatarobotCodeRef{ + CatalogID: newCatalogID, // different catalog to verify refresh + }) + + actions, err := RunRelink(context.Background(), RelinkOptions{ + ProjectDir: dir, + NewArtifactID: testArtifactID, // same id + Store: fakeStore(target), + Confirm: captureConfirm, + Goos: runtime.GOOS, + Now: func() time.Time { return fixedTime }, + }) + + require.NoError(t, err) + + assert.Equal(t, core.ActionPerformed, actions[0].Status) + + // Warning includes the same-id note. + assert.Contains(t, warningText, "same artifact") + assert.Contains(t, warningText, testArtifactID) + + // Config: artifactId unchanged, catalogId refreshed, lsv nil. + cfg, err := wapi.LoadConfig(dir) + + require.NoError(t, err) + + assert.Equal(t, testArtifactID, cfg.ArtifactID, "artifactId unchanged") + + require.NotNil(t, cfg.CatalogID) + + assert.Equal(t, newCatalogID, *cfg.CatalogID, "catalogId refreshed from target codeRef") + + assert.Nil(t, cfg.LastSyncedVersionID, "lsv reset to nil") + + // Manifest: empty BASE. + m, err := wapi.LoadManifest(dir) + + require.NoError(t, err) + + assert.Empty(t, m.Files) + + // History: from == to. + history, _ := os.ReadFile(filepath.Join(wapi.Dir(dir), "history.log")) + + assert.Contains(t, string(history), `"from":"`+testArtifactID+`"`) + assert.Contains(t, string(history), `"to":"`+testArtifactID+`"`) +} + +// TestRunRelink_NotLinked_ErrorPointsToInit verifies relink on a not-linked +// project returns ErrRelinkNotLinked without fetching. +func TestRunRelink_NotLinked_ErrorPointsToInit(t *testing.T) { + dir := t.TempDir() + + calls := 0 + + store := ArtifactGetterFunc(func(string) (*workload.Artifact, error) { + calls++ + + return fakeDraftArtifact(newArtifactID, nil), nil + }) + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, store, alwaysConfirm)) + + require.ErrorIs(t, err, ErrRelinkNotLinked) + + assert.Nil(t, actions) + + assert.Zero(t, calls, "no network fetch for a not-linked project") + + // No state dir created. + _, statErr := os.Stat(wapi.Dir(dir)) + + assert.ErrorIs(t, statErr, os.ErrNotExist, "no state dir created") +} + +// TestRunRelink_404Target_AbortsStateUntouched verifies a 404 target aborts +// with a skipped action and state untouched. +func TestRunRelink_404Target_AbortsStateUntouched(t *testing.T) { + dir := linkedProject(t) + + before := stateFileHashes(t, dir) + + store := errorStore(&drapi.HTTPError{StatusCode: 404, URL: "https://test/artifacts/x/"}) + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, store, alwaysConfirm)) + + require.ErrorIs(t, err, ErrRelinkAbort) + + require.Len(t, actions, 1) + + assert.Equal(t, core.ActionSkipped, actions[0].Status) + assert.Contains(t, actions[0].Reason, "not found") + + // State byte-identical. + assert.Equal(t, before, stateFileHashes(t, dir)) +} + +// TestRunRelink_LockedTarget_AbortsStateUntouched verifies a locked target +// aborts with a skipped action and writes nothing. +func TestRunRelink_LockedTarget_AbortsStateUntouched(t *testing.T) { + dir := linkedProject(t) + + before := stateFileHashes(t, dir) + + target := makeArtifact(newArtifactID, "LOCKED", nil) + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.ErrorIs(t, err, ErrRelinkAbort) + + require.Len(t, actions, 1) + + assert.Equal(t, core.ActionSkipped, actions[0].Status) + assert.Contains(t, actions[0].Reason, "locked") + + assert.Equal(t, before, stateFileHashes(t, dir)) +} + +// TestRunRelink_WrongType_AbortsStateUntouched verifies a non-service artifact +// type aborts with a skipped action. +func TestRunRelink_WrongType_AbortsStateUntouched(t *testing.T) { + dir := linkedProject(t) + + before := stateFileHashes(t, dir) + + target := &workload.Artifact{ + ID: newArtifactID, + Name: "agent-fixture", + Status: "DRAFT", + Type: "agent", + } + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.ErrorIs(t, err, ErrRelinkAbort) + + require.Len(t, actions, 1) + + assert.Equal(t, core.ActionSkipped, actions[0].Status) + assert.Contains(t, actions[0].Reason, "agent") + assert.Contains(t, actions[0].Reason, "service") + + assert.Equal(t, before, stateFileHashes(t, dir)) +} + +// TestRunRelink_EmptyTypeDefaultsToService covers the ArtifactTypeOrDefault +// behavior: an empty type defaults to service and passes the type gate. +func TestRunRelink_EmptyTypeDefaultsToService(t *testing.T) { + dir := linkedDraftProject(t) + + target := &workload.Artifact{ + ID: newArtifactID, + Name: "no-type-fixture", + Status: "DRAFT", + Type: "", // empty defaults to service + } + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.NoError(t, err) + + assert.Equal(t, core.ActionPerformed, actions[0].Status) +} + +// TestRunRelink_APIUnreachable_AbortsStateUntouched covers the API unreachable +// gate: any non-404 fetch error aborts with ErrRelinkAPIUnreachable. +func TestRunRelink_APIUnreachable_AbortsStateUntouched(t *testing.T) { + dir := linkedProject(t) + + before := stateFileHashes(t, dir) + + store := errorStore(errors.New("dial tcp 127.0.0.1:443: connect: connection refused")) + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, store, alwaysConfirm)) + + require.ErrorIs(t, err, ErrRelinkAPIUnreachable) + + assert.Nil(t, actions, "no actions for an API unreachable abort") + + assert.Equal(t, before, stateFileHashes(t, dir)) +} + +// TestRunRelink_LockHeld_AbortsStateUntouched verifies a held sync lock aborts +// the relink with "sync in progress" and state untouched. +func TestRunRelink_LockHeld_AbortsStateUntouched(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("flock semantics are unix-only; the windows gate path is covered by the seam tests") + } + + dir := linkedProject(t) + + require.NoError(t, os.WriteFile(lockPath(t, dir), nil, 0o600)) + + before := stateFileHashes(t, dir) + + release := holdLockForTest(t, dir) + + defer release() + + target := fakeDraftArtifact(newArtifactID, nil) + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.ErrorIs(t, err, ErrRelinkAbort) + + require.Len(t, actions, 1) + + assert.Equal(t, core.ActionSkipped, actions[0].Status) + assert.Contains(t, actions[0].Reason, "sync in progress") + + assert.Equal(t, before, stateFileHashes(t, dir)) +} + +// TestRunRelink_Declined_AbortsStateUntouched verifies declining the confirm +// prompt aborts with state untouched. +func TestRunRelink_Declined_AbortsStateUntouched(t *testing.T) { + dir := linkedProject(t) + + before := stateFileHashes(t, dir) + + target := fakeDraftArtifact(newArtifactID, nil) + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), neverConfirm)) + + require.ErrorIs(t, err, ErrRelinkAbort) + + require.Len(t, actions, 1) + + assert.Equal(t, core.ActionSkipped, actions[0].Status) + assert.Contains(t, actions[0].Reason, "declined") + + assert.Equal(t, before, stateFileHashes(t, dir)) +} + +// TestRunRelink_WindowsGate_Proceeds pins the windows gate behavior: the lock +// probe SKIPs (flock not enforced), and the relink still proceeds. +func TestRunRelink_WindowsGate_Proceeds(t *testing.T) { + dir := linkedDraftProject(t) + + target := fakeDraftArtifact(newArtifactID, nil) + + opts := relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm) + opts.Goos = "windows" + + actions, err := RunRelink(context.Background(), opts) + + require.NoError(t, err) + + assert.Equal(t, core.ActionPerformed, actions[0].Status) +} + +// TestRunRelink_RepeatedRelink_LastWins verifies relink A→B then B→C leaves +// config pointing at C with two history entries. +func TestRunRelink_RepeatedRelink_LastWins(t *testing.T) { + dir := linkedDraftProject(t) + + thirdID := "6a90da2ddeadbeefcafe9999" + + // A→B + targetB := fakeDraftArtifact(newArtifactID, nil) + + _, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(targetB), alwaysConfirm)) + + require.NoError(t, err) + + // B→C + targetC := fakeDraftArtifact(thirdID, nil) + + opts := relinkOpts(dir, thirdID, fakeStore(targetC), alwaysConfirm) + + _, err = RunRelink(context.Background(), opts) + + require.NoError(t, err) + + cfg, err := wapi.LoadConfig(dir) + + require.NoError(t, err) + + assert.Equal(t, thirdID, cfg.ArtifactID) + + // Two relink history entries. + history, _ := os.ReadFile(filepath.Join(wapi.Dir(dir), "history.log")) + + lines := splitLines(string(history)) + + var relinkLines []string + + for _, line := range lines { + if line == "" { + continue + } + + if line == "" { + continue + } + + if contains(line, `"op":"relink"`) { + relinkLines = append(relinkLines, line) + } + } + + require.Len(t, relinkLines, 2, "exactly two relink entries") + + assert.Contains(t, relinkLines[0], `"to":"`+newArtifactID+`"`) + assert.Contains(t, relinkLines[1], `"to":"`+thirdID+`"`) +} + +// TestRunRelink_PreservesCreatedAtAndCLIVersion pins that the relink does not +// reset the config's createdAt or cliVersion fields. +func TestRunRelink_PreservesCreatedAtAndCLIVersion(t *testing.T) { + dir := linkedProject(t) + + oldCfg, err := wapi.LoadConfig(dir) + + require.NoError(t, err) + + target := fakeDraftArtifact(newArtifactID, nil) + + _, err = RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.NoError(t, err) + + newCfg, err := wapi.LoadConfig(dir) + + require.NoError(t, err) + + assert.Equal(t, oldCfg.CreatedAt, newCfg.CreatedAt, "createdAt must be preserved") + assert.Equal(t, oldCfg.CLIVersion, newCfg.CLIVersion, "cliVersion must be preserved") +} + +// TestRunRelink_CorruptConfig_Aborts covers the case where the config is +// corrupt: the relink cannot read the old artifact id and aborts with an error. +func TestRunRelink_CorruptConfig_Aborts(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + writeStateFile(t, dir, "config.json", `{"artifactId":"abc`) + + target := fakeDraftArtifact(newArtifactID, nil) + + actions, err := RunRelink(context.Background(), relinkOpts(dir, newArtifactID, fakeStore(target), alwaysConfirm)) + + require.Error(t, err) + + assert.Nil(t, actions) + + assert.Contains(t, err.Error(), "doctor --fix") +} + +// splitLines splits a string on newlines, dropping trailing empty lines. +func splitLines(s string) []string { + lines := []string{} + + for _, line := range splitNewlines(s) { + if line != "" { + lines = append(lines, line) + } + } + + return lines +} + +// splitNewlines splits on \n without allocating a trailing empty element. +func splitNewlines(s string) []string { + var lines []string + + start := 0 + + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + + start = i + 1 + } + } + + if start < len(s) { + lines = append(lines, s[start:]) + } + + return lines +} + +// contains is a simple substring check (avoids importing strings in test). +func contains(s, substr string) bool { + return len(s) >= len(substr) && findSubstring(s, substr) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + + return false +} diff --git a/internal/workload/doctor/remedies.go b/internal/workload/doctor/remedies.go new file mode 100644 index 000000000..a64e30d9b --- /dev/null +++ b/internal/workload/doctor/remedies.go @@ -0,0 +1,74 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doctor + +// Canonical remedy strings, one exact string per check condition. They are +// owned by this package and reused verbatim by both reporters (text and +// JSON), so the command layer must render them as-is rather than rewording. +const ( + // RemedyPresence is shown when the project has no state directory: + // linking is the only way in. + RemedyPresence = "dr artifact code init " + + // RemedyConfig is shown when config.json is missing, corrupt, or fails + // semantic validation. The config is the source of truth and cannot be + // auto-rebuilt (a `--fix` manifest rebuild requires a valid config), so + // recovery is re-initialization. + RemedyConfig = "dr artifact code init " + + // RemedyManifest is shown when manifest.json is missing, corrupt, or + // fails semantic validation: `--fix` rebuilds an empty BASE from config. + RemedyManifest = "dr artifact code doctor --fix (rebuilds an empty BASE from config)" + + // RemedyDivergence is shown when config lastSyncedVersionId and manifest + // syncedVersionId disagree: `--fix` resets the manifest from config. + RemedyDivergence = "dr artifact code doctor --fix (resets the manifest from config)" + + // RemedyRollback is shown when an interrupted rollback tree exists: + // `--fix` restores the backed-up files and clears the tree. + RemedyRollback = "dr artifact code doctor --fix (restores backed-up files and clears .rollback/)" + + // RemedyLockHeld is shown when a live process holds sync.lock. Quitting + // the holder (never removing the lock file) is the only safe recovery. + RemedyLockHeld = "identify and quit the process holding the sync lock (e.g. another 'dr artifact code sync'), then re-run this command" + + // RemedyLockInspect is shown when the lock file cannot be inspected + // (permission or I/O error). This is NOT a held-lock condition. + RemedyLockInspect = "check permissions on the sync state directory and sync.lock, then re-run this command" + + // RemedyRelink is shown when the linked artifact is gone (404): the only + // recovery is repointing the project at a new artifact with a fresh BASE. + RemedyRelink = "dr artifact code doctor --relink " + + // RemedyArtifactLocked is shown when the linked artifact is locked + // (locking is one-way). Sync execution is refused but preview works; work + // against a draft instead, or relink to one. + RemedyArtifactLocked = "work against a draft artifact, or relink to one: dr artifact code doctor --relink " + + // RemedyCatalogMismatch is shown when the locally pinned catalog id no + // longer matches the artifact's codeRef: the pin is stale server-side. + RemedyCatalogMismatch = "dr artifact code doctor --relink (or re-init against the intended artifact)" + + // RemedyDrift is shown when the artifact's codeRef version no longer + // matches the last-synced version. Review what a sync would do first; + // relink starts a fresh baseline instead. + RemedyDrift = "review with 'dr artifact code sync --dry-run', or relink to start fresh: dr artifact code doctor --relink " + + // RemedyRemoteConnectivity is shown when a remote check could not reach + // the API for ANY reason other than a 404 (unauthenticated, 401/403, + // 5xx, timeout, unreachable endpoint). It deliberately never mentions + // --relink: a fetch failure is not evidence that the artifact is gone. + RemedyRemoteConnectivity = "run 'dr auth login' or fix network connectivity, then re-run this command" +) diff --git a/internal/workload/doctor/remote.go b/internal/workload/doctor/remote.go new file mode 100644 index 000000000..1956e9ab8 --- /dev/null +++ b/internal/workload/doctor/remote.go @@ -0,0 +1,452 @@ +// 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 doctor + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// Stable check identifiers for the remote checks. They surface in reports +// and JSON output, so they must not change between releases. +const ( + CheckIDArtifactExists = "remote.artifact-exists" + CheckIDArtifactLocked = "remote.artifact-locked" + CheckIDCatalogMismatch = "remote.catalog-mismatch" + CheckIDDrift = "remote.drift" +) + +// ArtifactGetter is the doctor's remote seam: the small surface of the +// artifact API the remote checks depend on. Production uses +// ProductionArtifactGetter (delegating to workload.GetArtifact); tests inject +// a fake so no network is touched. +type ArtifactGetter interface { + // Get fetches the artifact by id, mirroring workload.GetArtifact. + Get(artifactID string) (*workload.Artifact, error) +} + +// ArtifactGetterFunc adapts a plain function to the ArtifactGetter seam so +// the command layer can hand over its test seam variable directly. +type ArtifactGetterFunc func(artifactID string) (*workload.Artifact, error) + +// Get implements ArtifactGetter. +func (f ArtifactGetterFunc) Get(artifactID string) (*workload.Artifact, error) { + return f(artifactID) +} + +// ProductionArtifactGetter returns the seam implementation backed by the +// real workload package (workload.GetArtifact). +func ProductionArtifactGetter() ArtifactGetter { + return ArtifactGetterFunc(workload.GetArtifact) +} + +// RemoteChecks returns the four remote sync-state checks in fixed report +// order: artifact-exists, artifact-locked, catalog-mismatch, drift. All four +// share one artifact snapshot fetched through store (exactly one GetArtifact +// per run; TOCTOU within a run collapses to one read). +// +// Each check re-reads local state at Run time (same pattern as the local +// checks), so the SKIP cascades (unlinked project, unreadable config) are +// honest per-run observations rather than construction-time snapshots. +func RemoteChecks(projectDir string, store ArtifactGetter) []core.Check { + snapshot := &remoteSnapshot{store: store} + + return []core.Check{ + &artifactExistsCheck{remoteBase{projectDir: projectDir, snapshot: snapshot}}, + &artifactLockedCheck{remoteBase{projectDir: projectDir, snapshot: snapshot}}, + &catalogMismatchCheck{remoteBase{projectDir: projectDir, snapshot: snapshot}}, + &driftCheck{remoteBase{projectDir: projectDir, snapshot: snapshot}}, + } +} + +// remoteSnapshot lazily fetches the linked artifact exactly once and hands +// the same snapshot to every remote check in the run. If the fetch fails, +// every check sees the same error — a mid-run disappearance can produce at +// most one fetch, never a partial per-check picture. +type remoteSnapshot struct { + store ArtifactGetter + + once sync.Once + + artifact *workload.Artifact + + err error +} + +// get returns the shared snapshot, fetching it on first use with the given +// artifact id. Subsequent calls (whatever id they pass) return the memoized +// result: within one run the config cannot legitimately change identity. +func (s *remoteSnapshot) get(artifactID string) (*workload.Artifact, error) { + s.once.Do(func() { + s.artifact, s.err = s.store.Get(artifactID) + }) + + return s.artifact, s.err +} + +// remoteBase is the shared plumbing of the four remote checks: the project +// directory plus the shared snapshot. +type remoteBase struct { + projectDir string + + snapshot *remoteSnapshot +} + +// linkedConfig resolves the local preconditions every remote check depends +// on: a linked state dir and a readable config carrying a usable artifact id. +// On failure it returns a SKIP result (honest cascade reporting) and ok=false. +func (b remoteBase) linkedConfig() (wapi.Config, core.Result, bool) { + if res, skip := skipIfUnlinked(b.projectDir); skip { + return wapi.Config{}, res, false + } + + cfg, err := wapi.LoadConfig(b.projectDir) + if err != nil { + return wapi.Config{}, core.Result{ + Status: core.StatusSKIP, + Summary: "linked state is unreadable; cannot determine the linked artifact id", + Remedy: RemedyConfig, + }, false + } + + if normalizeStringPtr(&cfg.ArtifactID) == nil { + return wapi.Config{}, core.Result{ + Status: core.StatusSKIP, + Summary: "config carries no usable artifact id", + Remedy: RemedyConfig, + }, false + } + + return cfg, core.Result{}, true +} + +// fetchedArtifact returns the shared artifact snapshot after the local +// preconditions pass. A 404 maps to notFound=true (the caller decides +// whether that is its own FAIL or a SKIP); any other fetch failure maps to +// a SKIP with the connectivity remedy — never a misleading OK and never +// FAIL-as-deleted. +func (b remoteBase) fetchedArtifact(cfg wapi.Config) (art *workload.Artifact, res core.Result, notFound, ok bool) { + art, err := b.snapshot.get(cfg.ArtifactID) + if err == nil { + return art, core.Result{}, false, true + } + + if isNotFound(err) { + // artifact-exists owns the deleted finding; the dependent checks + // honestly SKIP rather than piling on with findings of their own. + return nil, core.Result{ + Status: core.StatusSKIP, + Summary: "linked artifact not found; nothing to check", + }, true, false + } + + return nil, remoteSkipResult(err), false, false +} + +// remoteSkipResult builds the SKIP every non-404 remote failure maps to. The +// remedy names re-authentication/connectivity and never mentions --relink: +// nothing here suggests the artifact is gone when the evidence only says the +// API is out of reach. +func remoteSkipResult(err error) core.Result { + return core.Result{ + Status: core.StatusSKIP, + Summary: fmt.Sprintf("could not fetch the linked artifact: %s", err), + Remedy: RemedyRemoteConnectivity, + } +} + +// IsNotFound reports whether err is the API's 404 (possibly wrapped), +// detected via drapi.HTTPError status rather than string matching. It is +// the single shared implementation used by both the doctor remote checks and +// the init already-linked branch, so the two surfaces cannot drift apart. +func IsNotFound(err error) bool { + var httpErr *drapi.HTTPError + + return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound +} + +// isNotFound is an internal alias kept for the remote checks' own call sites +// that already use the unexported name. It delegates to the exported +// IsNotFound so there is exactly one implementation. +func isNotFound(err error) bool { + return IsNotFound(err) +} + +// IsCatalogMismatch reports whether the locally pinned catalog id no longer +// matches the artifact's codeRef. The anchor-on-local rule applies: a +// nil/empty local pin means "never synced from here" and is always OK (no +// mismatch). It is the single shared implementation used by both the doctor +// catalog-mismatch check and the init already-linked branch. +func IsCatalogMismatch(localCatalogID *string, art *workload.Artifact) bool { + if localCatalogID == nil || *localCatalogID == "" { + return false + } + + codeRef := workload.ExtractCodeRef(*art) + if codeRef == nil || codeRef.CatalogID == "" { + return true // local pin set, remote absent/empty + } + + return *localCatalogID != codeRef.CatalogID +} + +// codeRefCatalog returns the artifact's pinned catalog id with empty-vs-nil +// normalization: no usable codeRef or an empty field reads as absent. +func codeRefCatalog(art *workload.Artifact) *string { + codeRef := workload.ExtractCodeRef(*art) + + if codeRef == nil { + return nil + } + + return normalizeStringPtr(&codeRef.CatalogID) +} + +// codeRefVersion returns the artifact's catalog version id with the same +// empty-vs-nil normalization. +func codeRefVersion(art *workload.Artifact) *string { + codeRef := workload.ExtractCodeRef(*art) + + if codeRef == nil { + return nil + } + + return normalizeStringPtr(&codeRef.CatalogVersionID) +} + +// artifactExistsCheck verifies that the linked artifact still exists +// remotely. It is the only check allowed to interpret a 404, and the only +// one that FAILs on one. +type artifactExistsCheck struct { + remoteBase +} + +func (c *artifactExistsCheck) ID() string { + return CheckIDArtifactExists +} + +func (c *artifactExistsCheck) Name() string { + return "Linked artifact exists" +} + +// Run fetches the shared snapshot. 404 → FAIL (deleted, --relink remedy); +// any other failure → SKIP (connectivity remedy); success → OK. +func (c *artifactExistsCheck) Run(_ context.Context) core.Result { + cfg, res, ok := c.linkedConfig() + if !ok { + return res + } + + _, res, notFound, ok := c.fetchedArtifact(cfg) + + switch { + case !ok && notFound: + return core.Result{ + Status: core.StatusFAIL, + Summary: "linked artifact not found (deleted?)", + Remedy: RemedyRelink, + } + case !ok: + return res + } + + return core.Result{ + Status: core.StatusOK, + Summary: "linked artifact exists", + } +} + +// artifactLockedCheck reports whether the linked artifact is locked. Locking +// is one-way and blocks sync execution (preview still works), so it is a +// WARN with fixable=false, never a FAIL and never --fix repairable. +type artifactLockedCheck struct { + remoteBase +} + +func (c *artifactLockedCheck) ID() string { + return CheckIDArtifactLocked +} + +func (c *artifactLockedCheck) Name() string { + return "Artifact lock state" +} + +// Run judges only the lock state; a 404 SKIPs (artifact-exists owns that +// finding), any other fetch failure SKIPs with the connectivity remedy. +func (c *artifactLockedCheck) Run(_ context.Context) core.Result { + cfg, res, ok := c.linkedConfig() + if !ok { + return res + } + + art, res, _, ok := c.fetchedArtifact(cfg) + if !ok { + return res + } + + if art.IsLocked() { + return core.Result{ + Status: core.StatusWARN, + Summary: "artifact is locked: sync execute refused, preview still allowed", + Remedy: RemedyArtifactLocked, + Fixable: false, + } + } + + return core.Result{ + Status: core.StatusOK, + Summary: "artifact is a draft (not locked)", + } +} + +// catalogMismatchCheck compares the locally pinned catalog id +// (config.CatalogID) with the artifact's codeRef catalog id. A mismatch +// means the artifact was re-pointed server-side; sync would target the wrong +// lineage, so it FAILs with a relink remedy. Both-absent (never synced) +// agrees. +type catalogMismatchCheck struct { + remoteBase +} + +func (c *catalogMismatchCheck) ID() string { + return CheckIDCatalogMismatch +} + +func (c *catalogMismatchCheck) Name() string { + return "Catalog pin match" +} + +// Run compares the two catalog pointers with empty≈nil normalization on both +// sides. A 404 SKIPs (artifact-exists owns it); other fetch failures SKIP. +func (c *catalogMismatchCheck) Run(_ context.Context) core.Result { + cfg, res, ok := c.linkedConfig() + if !ok { + return res + } + + art, res, _, ok := c.fetchedArtifact(cfg) + if !ok { + return res + } + + local := normalizeStringPtr(cfg.CatalogID) + + remote := codeRefCatalog(art) + + // Comparisons anchor on the local pin: nothing pinned locally is the + // healthy never-synced state, while a pin whose remote counterpart + // vanished is as divergent as a different catalog id. + switch { + case local == nil: + return core.Result{ + Status: core.StatusOK, + Summary: "catalog not pinned (never synced)", + } + case remote == nil: + return catalogMismatchResult(local, remote) + case *local == *remote: + return core.Result{ + Status: core.StatusOK, + Summary: fmt.Sprintf("catalog pin matches the artifact (%s)", *local), + } + default: + return catalogMismatchResult(local, remote) + } +} + +// catalogMismatchResult builds the FAIL for a one-sided or divergent catalog +// pin, naming both values (null when absent) so the report shows exactly +// which side moved. +func catalogMismatchResult(local, remote *string) core.Result { + return core.Result{ + Status: core.StatusFAIL, + Summary: fmt.Sprintf("catalog mismatch: config pinned %s but artifact codeRef points at %s", ptrDisplay(local), ptrDisplay(remote)), + Remedy: RemedyCatalogMismatch, + } +} + +// driftCheck compares the artifact's current codeRef catalog version with +// the locally last-synced version. A difference means the remote moved on; +// the next sync reconciles (possibly overwriting), so it WARNs and points at +// a dry-run review. Both-absent (never synced) cannot drift. +type driftCheck struct { + remoteBase +} + +func (c *driftCheck) ID() string { + return CheckIDDrift +} + +func (c *driftCheck) Name() string { + return "Remote version drift" +} + +// Run compares the two version pointers with empty≈nil normalization. A 404 +// SKIPs; other fetch failures SKIP with the connectivity remedy. +func (c *driftCheck) Run(_ context.Context) core.Result { + cfg, res, ok := c.linkedConfig() + if !ok { + return res + } + + art, res, _, ok := c.fetchedArtifact(cfg) + if !ok { + return res + } + + local := normalizeStringPtr(cfg.LastSyncedVersionID) + + remote := codeRefVersion(art) + + // Anchored on the local baseline: before a first sync there is no + // baseline to drift from, while a baseline whose remote counterpart + // vanished has drifted as surely as one pointing elsewhere. + switch { + case local == nil: + return core.Result{ + Status: core.StatusOK, + Summary: "no synced version yet; nothing to drift", + } + case remote == nil: + return driftResult(local, remote) + case *local == *remote: + return core.Result{ + Status: core.StatusOK, + Summary: "in sync with the last synced version", + } + default: + return driftResult(local, remote) + } +} + +// driftResult builds the WARN for divergent version pointers, naming both +// values and pointing at a dry-run review rather than an automatic repair. +func driftResult(local, remote *string) core.Result { + return core.Result{ + Status: core.StatusWARN, + + Summary: fmt.Sprintf("remote version drifted: last synced %s but artifact codeRef now points at %s; next sync reconciles", ptrDisplay(local), ptrDisplay(remote)), + + Remedy: RemedyDrift, + } +} diff --git a/internal/workload/doctor/remote_test.go b/internal/workload/doctor/remote_test.go new file mode 100644 index 000000000..355cf4b21 --- /dev/null +++ b/internal/workload/doctor/remote_test.go @@ -0,0 +1,514 @@ +// 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 doctor + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeArtifactStore is the doctor's in-memory ArtifactGetter. It counts Get +// calls (the single-fetch contract) and can be rigged to return an artifact, +// an error, or both. +type fakeArtifactStore struct { + getCalls int + + artifact *workload.Artifact + + err error +} + +func (f *fakeArtifactStore) Get(_ string) (*workload.Artifact, error) { + f.getCalls++ + + return f.artifact, f.err +} + +// testArtifact builds an artifact fixture with the given status and an +// optional codeRef planted on the primary container. +func testArtifact(status string, codeRef *workload.DatarobotCodeRef) *workload.Artifact { + art := &workload.Artifact{ + ID: testArtifactID, + Name: "doctor-test", + Status: status, + } + + if codeRef == nil { + return art + } + + primary := true + + art.Spec.ContainerGroups = []workload.ContainerGroup{ + { + Containers: []workload.Container{ + { + Primary: &primary, + + ImageBuildConfig: &workload.ImageBuildConfig{ + CodeRef: &workload.CodeRef{Datarobot: codeRef}, + }, + }, + }, + }, + } + + return art +} + +// errNotFound is the 404 the doctor must map to FAIL-as-deleted. +var errNotFound = &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "https://test/artifacts/x/"} + +// errServer is a representative non-404 failure (any non-404 must SKIP). +var errServer = &drapi.HTTPError{StatusCode: http.StatusInternalServerError, URL: "https://test/artifacts/x/"} + +// errUnreachable is a connection-level failure with no HTTP status at all. +var errUnreachable = errors.New("get artifact: dial tcp: connection refused") + +// runRemoteChecks runs the four remote checks against the fixture store and +// returns the results in the fixed remote-check order. +func runRemoteChecks(t *testing.T, projectDir string, store *fakeArtifactStore) []core.Result { + t.Helper() + + return core.NewRunner(RemoteChecks(projectDir, store)...).Run(context.Background()) +} + +// byID indexes results by check id for lookups by id. +func byID(results []core.Result) map[string]core.Result { + m := make(map[string]core.Result, len(results)) + + for _, res := range results { + m[res.CheckID] = res + } + + return m +} + +func TestRemoteChecks_FixedOrder(t *testing.T) { + ids := make([]string, 0, 4) + + for _, c := range RemoteChecks(t.TempDir(), &fakeArtifactStore{}) { + ids = append(ids, c.ID()) + } + + assert.Equal(t, []string{ + CheckIDArtifactExists, + CheckIDArtifactLocked, + CheckIDCatalogMismatch, + CheckIDDrift, + }, ids) +} + +func TestRemoteChecks_HealthyDraft_AllOK(t *testing.T) { + // Never-synced draft: no codeRef on the artifact, no pointers in config. + // Everything must be OK — a fresh link is healthy, not drifted. + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + store := &fakeArtifactStore{artifact: testArtifact("draft", nil)} + + results := runRemoteChecks(t, dir, store) + + require.Len(t, results, 4) + + for _, res := range results { + assert.Equal(t, core.StatusOK, res.Status, "check %s: summary %q", res.CheckID, res.Summary) + } +} + +func TestRemoteChecks_SingleFetch_CallCount(t *testing.T) { + // All four checks share ONE GetArtifact snapshot per run: the fake store + // must observe exactly one Get call no matter how many checks run. + dir := healthyProject(t) + + store := &fakeArtifactStore{artifact: testArtifact("draft", &workload.DatarobotCodeRef{ + CatalogID: testCatalogID, + CatalogVersionID: testVersionID, + })} + + results := runRemoteChecks(t, dir, store) + + for _, res := range results { + assert.Equal(t, core.StatusOK, res.Status, "check %s: summary %q", res.CheckID, res.Summary) + } + + assert.Equal(t, 1, store.getCalls, "exactly one artifact fetch per doctor run") +} + +func TestRemoteChecks_404_ArtifactExistsFAIL_OthersSKIP(t *testing.T) { + dir := healthyProject(t) + + store := &fakeArtifactStore{err: errNotFound} + + results := runRemoteChecks(t, dir, store) + + res := byID(results) + + exists := res[CheckIDArtifactExists] + + assert.Equal(t, core.StatusFAIL, exists.Status) + assert.Contains(t, exists.Summary, "deleted") + assert.Contains(t, exists.Remedy, "--relink") + + // The dependent checks must SKIP (ordering contract): a vanished artifact + // is artifact-exists' finding, not theirs. + for _, id := range []string{CheckIDArtifactLocked, CheckIDCatalogMismatch, CheckIDDrift} { + assert.Equal(t, core.StatusSKIP, res[id].Status, "check %s should SKIP on 404", id) + } +} + +func TestRemoteChecks_Wrapped404_StillFAIL(t *testing.T) { + // 404 detection goes through errors.As, so a wrapped HTTPError still maps + // to FAIL-as-deleted. + dir := healthyProject(t) + + store := &fakeArtifactStore{err: fmt.Errorf("fetch artifact: %w", errNotFound)} + + results := runRemoteChecks(t, dir, store) + + assert.Equal(t, core.StatusFAIL, byID(results)[CheckIDArtifactExists].Status) +} + +func TestRemoteChecks_Non404_AllSKIP_NeverDeleted(t *testing.T) { + for name, err := range map[string]error{ + "500": errServer, + "unauthorized": &drapi.HTTPError{StatusCode: http.StatusUnauthorized, URL: "https://test/"}, + "forbidden": &drapi.HTTPError{StatusCode: http.StatusForbidden, URL: "https://test/"}, + "unreachable": errUnreachable, + } { + t.Run(name, func(t *testing.T) { + dir := healthyProject(t) + + store := &fakeArtifactStore{err: err} + + results := runRemoteChecks(t, dir, store) + + require.Len(t, results, 4) + + for _, res := range results { + assert.Equal(t, core.StatusSKIP, res.Status, + "check %s: any non-404 failure must SKIP, never FAIL-as-deleted", res.CheckID) + + assert.NotContains(t, res.Summary, "deleted") + + assert.NotContains(t, res.Remedy, "--relink", + "the connectivity remedy must never mention relink") + + assert.NotEmpty(t, res.Remedy, "SKIP still carries a remedy") + } + }) + } +} + +func TestRemoteChecks_Unlinked_AllSKIP(t *testing.T) { + store := &fakeArtifactStore{} + + results := runRemoteChecks(t, t.TempDir(), store) + + require.Len(t, results, 4) + + for _, res := range results { + assert.Equal(t, core.StatusSKIP, res.Status, "check %s", res.CheckID) + + assert.Contains(t, res.Summary, "no linked state") + } + + assert.Zero(t, store.getCalls, "remote checks never fetch without linked state") +} + +func TestRemoteChecks_ConfigMissing_AllSKIP(t *testing.T) { + // State dir present (presence OK) but config.json gone: the artifact id + // is unknowable, so every remote check SKIPs. + dir := t.TempDir() + + initStateDir(t, dir) + + store := &fakeArtifactStore{} + + results := runRemoteChecks(t, dir, store) + + for _, res := range results { + assert.Equal(t, core.StatusSKIP, res.Status, "check %s", res.CheckID) + } + + assert.Zero(t, store.getCalls) +} + +func TestRemoteChecks_LockedArtifact_WARN_NeverFAIL(t *testing.T) { + dir := healthyProject(t) // config pins testCatalogID/testVersionID + + // The artifact carries a matching codeRef so only the lock state varies. + store := &fakeArtifactStore{artifact: testArtifact("locked", &workload.DatarobotCodeRef{ + CatalogID: testCatalogID, + CatalogVersionID: testVersionID, + })} + + results := runRemoteChecks(t, dir, store) + + res := byID(results) + + locked := res[CheckIDArtifactLocked] + + assert.Equal(t, core.StatusWARN, locked.Status, "locked must WARN, never FAIL") + assert.False(t, locked.Fixable, "nothing --fix can do about a remote lock") + assert.Contains(t, locked.Summary, "preview") + assert.Contains(t, locked.Summary, "execute") + + // The other three checks still judge the artifact itself. + assert.Equal(t, core.StatusOK, res[CheckIDArtifactExists].Status) + assert.Equal(t, core.StatusOK, res[CheckIDCatalogMismatch].Status) + assert.Equal(t, core.StatusOK, res[CheckIDDrift].Status) +} + +func TestRemoteChecks_CatalogMismatch_FAIL_OnlyOwnCheck(t *testing.T) { + dir := healthyProject(t) // config pins testCatalogID/testVersionID + + // The artifact's codeRef points at a different catalog but the SAME + // version, so drift must stay OK — proving the mismatch is scoped to the + // catalog comparison only. + store := &fakeArtifactStore{artifact: testArtifact("draft", &workload.DatarobotCodeRef{ + CatalogID: "65ffffffffffffffffffffff", + CatalogVersionID: testVersionID, + })} + + results := runRemoteChecks(t, dir, store) + + res := byID(results) + + mismatch := res[CheckIDCatalogMismatch] + + assert.Equal(t, core.StatusFAIL, mismatch.Status) + assert.Contains(t, mismatch.Summary, testCatalogID) + assert.Contains(t, mismatch.Remedy, "--relink") + + assert.Equal(t, core.StatusOK, res[CheckIDArtifactExists].Status) + assert.Equal(t, core.StatusOK, res[CheckIDArtifactLocked].Status) + assert.Equal(t, core.StatusOK, res[CheckIDDrift].Status) +} + +func TestRemoteChecks_Drift_WARN_OnlyOwnCheck(t *testing.T) { + dir := healthyProject(t) // config pins testVersionID + + // The catalog still matches; only the version moved, so catalog-mismatch + // must stay OK — proving drift is scoped to the version comparison only. + store := &fakeArtifactStore{artifact: testArtifact("draft", &workload.DatarobotCodeRef{ + CatalogID: testCatalogID, + CatalogVersionID: "65fffffffffffffffffffffe", + })} + + results := runRemoteChecks(t, dir, store) + + res := byID(results) + + drift := res[CheckIDDrift] + + assert.Equal(t, core.StatusWARN, drift.Status, "drift must WARN, never FAIL") + assert.Contains(t, drift.Remedy, "sync --dry-run") + assert.False(t, drift.Fixable) + + assert.Equal(t, core.StatusOK, res[CheckIDArtifactExists].Status) + assert.Equal(t, core.StatusOK, res[CheckIDArtifactLocked].Status) + assert.Equal(t, core.StatusOK, res[CheckIDCatalogMismatch].Status) +} + +func TestRemoteChecks_EmptyCodeRefFields_NormalizedToNil(t *testing.T) { + // ExtractCodeRef may return a non-nil pointer with empty fields; empty + // must be treated as absent so a never-synced artifact with an empty + // codeRef reports OK, not a spurious mismatch/drift. + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + store := &fakeArtifactStore{artifact: testArtifact("draft", &workload.DatarobotCodeRef{ + CatalogID: "", + CatalogVersionID: "", + })} + + results := runRemoteChecks(t, dir, store) + + res := byID(results) + + assert.Equal(t, core.StatusOK, res[CheckIDCatalogMismatch].Status) + assert.Equal(t, core.StatusOK, res[CheckIDDrift].Status) +} + +func TestRemoteChecks_EmptyConfigPointers_NormalizedToNil(t *testing.T) { + // Config pointers to "" behave as nil (empty ≈ nil) even when the artifact + // has a real codeRef: an empty pinned pointer pins nothing. + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig("", ""))) + + store := &fakeArtifactStore{artifact: testArtifact("draft", &workload.DatarobotCodeRef{ + CatalogID: testCatalogID, + CatalogVersionID: testVersionID, + })} + + results := runRemoteChecks(t, dir, store) + + res := byID(results) + + assert.Equal(t, core.StatusOK, res[CheckIDCatalogMismatch].Status) + assert.Equal(t, core.StatusOK, res[CheckIDDrift].Status) +} + +func TestRemoteChecks_ReadOnly_StateUntouched(t *testing.T) { + dir := healthyProject(t) + + store := &fakeArtifactStore{artifact: testArtifact("draft", nil)} + + before := stateFileHashes(t, dir) + + runRemoteChecks(t, dir, store) + + assert.Equal(t, before, stateFileHashes(t, dir), "remote checks are read-only diagnostics") +} + +func TestRemoteChecks_ErrorSummaryIsInformative(t *testing.T) { + // A SKIP must say why it skipped: the underlying error text appears in + // the summary so 401 vs 5xx vs unreachable are distinguishable. + dir := healthyProject(t) + + store := &fakeArtifactStore{err: errServer} + + results := runRemoteChecks(t, dir, store) + + for _, res := range results { + assert.True(t, + strings.Contains(res.Summary, errServer.Error()) || strings.Contains(res.Summary, "500"), + "check %s summary %q should carry the underlying error", res.CheckID, res.Summary) + } +} + +// Compile-time proof that the production store satisfies the seam. +var _ ArtifactGetter = ProductionArtifactGetter() + +// TestIsNotFound verifies the shared 404-detection predicate used by both the +// doctor remote checks and the init already-linked branch. +func TestIsNotFound(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "404 HTTPError", + err: &drapi.HTTPError{StatusCode: 404, URL: "test"}, + want: true, + }, + { + name: "500 HTTPError", + err: &drapi.HTTPError{StatusCode: 500, URL: "test"}, + want: false, + }, + { + name: "wrapped 404", + err: fmt.Errorf("fetch failed: %w", &drapi.HTTPError{StatusCode: 404, URL: "test"}), + want: true, + }, + { + name: "plain error", + err: errors.New("connection refused"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsNotFound(tt.err)) + }) + } +} + +// TestIsCatalogMismatch verifies the shared catalog-mismatch predicate used by +// both the doctor catalog-mismatch check and the init already-linked branch. +func TestIsCatalogMismatch(t *testing.T) { + matchingCodeRef := &workload.DatarobotCodeRef{CatalogID: "cat-123"} + mismatchedCodeRef := &workload.DatarobotCodeRef{CatalogID: "cat-456"} + emptyCodeRef := &workload.DatarobotCodeRef{CatalogID: ""} + + tests := []struct { + name string + local *string + art *workload.Artifact + want bool + }{ + { + name: "nil local pin always OK (anchor-on-local)", + local: nil, + art: makeArtifact("id", "DRAFT", mismatchedCodeRef), + want: false, + }, + { + name: "empty local pin always OK", + local: strPtr(""), + art: makeArtifact("id", "DRAFT", mismatchedCodeRef), + want: false, + }, + { + name: "matching catalogs OK", + local: strPtr("cat-123"), + art: makeArtifact("id", "DRAFT", matchingCodeRef), + want: false, + }, + { + name: "mismatched catalogs FAIL", + local: strPtr("cat-123"), + art: makeArtifact("id", "DRAFT", mismatchedCodeRef), + want: true, + }, + { + name: "local pin set, remote codeRef absent FAIL", + local: strPtr("cat-123"), + art: makeArtifact("id", "DRAFT", nil), + want: true, + }, + { + name: "local pin set, remote codeRef empty FAIL", + local: strPtr("cat-123"), + art: makeArtifact("id", "DRAFT", emptyCodeRef), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsCatalogMismatch(tt.local, tt.art)) + }) + } +} diff --git a/internal/workload/doctor/rollback.go b/internal/workload/doctor/rollback.go new file mode 100644 index 000000000..a521cb278 --- /dev/null +++ b/internal/workload/doctor/rollback.go @@ -0,0 +1,63 @@ +// 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 doctor + +import ( + "context" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/fsutil" + "github.com/datarobot/cli/internal/workload/wapi" +) + +// rollbackCheck reports whether an interrupted sync left a stale rollback +// tree behind. The tree's existence — even empty — is the evidence: a sync +// crashed between staging its backups and clearing them. +type rollbackCheck struct { + projectDir string +} + +func (c *rollbackCheck) ID() string { + return CheckIDRollback +} + +func (c *rollbackCheck) Name() string { + return "Interrupted rollback" +} + +// Run looks for a .rollback/ tree at every location wapi.StaleRollbackDirs +// knows about (current and legacy), without touching any of its contents. +func (c *rollbackCheck) Run(_ context.Context) core.Result { + if res, skip := skipIfUnlinked(c.projectDir); skip { + return res + } + + for _, dir := range wapi.StaleRollbackDirs(c.projectDir) { + if fsutil.DirExists(dir) { + return core.Result{ + Status: core.StatusFAIL, + Summary: "interrupted rollback present at " + absPath(dir), + Remedy: RemedyRollback, + Details: map[string]string{"path": absPath(dir)}, + Fixable: true, + } + } + } + + return core.Result{ + Status: core.StatusOK, + Summary: "no interrupted rollback", + } +} diff --git a/internal/workload/doctor/rollback_test.go b/internal/workload/doctor/rollback_test.go new file mode 100644 index 000000000..593a3ee5b --- /dev/null +++ b/internal/workload/doctor/rollback_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doctor + +import ( + "context" + "os" + "path/filepath" + "testing" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func rollbackDir(t *testing.T, projectDir string) string { + t.Helper() + + return filepath.Join(wapi.Dir(projectDir), wapi.RollbackDirName) +} + +func TestRollbackCheck_OK_NoRollbackDir(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + res := (&rollbackCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusOK, res.Status) + + assert.Empty(t, res.Remedy) +} + +func TestRollbackCheck_FAIL_StaleDirWithFiles(t *testing.T) { + dir := t.TempDir() + + initStateDir(t, dir) + + backedUp := filepath.Join(rollbackDir(t, dir), "app", "main.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(backedUp), 0o755)) + + require.NoError(t, os.WriteFile(backedUp, []byte("package main\n"), 0o600)) + + res := (&rollbackCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) + + assert.Equal(t, RemedyRollback, res.Remedy) + + assert.True(t, res.Fixable) +} + +func TestRollbackCheck_FAIL_EmptyDir(t *testing.T) { + dir := t.TempDir() + + // An empty .rollback/ still means an interrupted rollback: the dir + // itself is the evidence, not its contents. + require.NoError(t, os.MkdirAll(rollbackDir(t, dir), 0o755)) + + res := (&rollbackCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) +} + +func TestRollbackCheck_FAIL_LegacyPath(t *testing.T) { + dir := t.TempDir() + + // Legacy-only project: state lives in .wapi, so the stale rollback tree + // hides under .wapi/.rollback. + legacyRollback := filepath.Join(dir, wapi.LegacyDirName, wapi.RollbackDirName) + + require.NoError(t, os.MkdirAll(legacyRollback, 0o755)) + + writeStateFile(t, dir, "config.json", validConfigJSON()) + + // wapi.Dir now resolves to the legacy location; sanity-check that the + // state file landed there before asserting the check sweeps it. + _, err := os.Stat(filepath.Join(dir, wapi.LegacyDirName, "config.json")) + + require.NoError(t, err) + + res := (&rollbackCheck{projectDir: dir}).Run(context.Background()) + + assert.Equal(t, core.StatusFAIL, res.Status) +} + +func TestRollbackCheck_SKIP_NotLinked(t *testing.T) { + res := (&rollbackCheck{projectDir: t.TempDir()}).Run(context.Background()) + + assert.Equal(t, core.StatusSKIP, res.Status) + + assert.Contains(t, res.Summary, "no linked state") +} diff --git a/internal/workload/doctor/suite_test.go b/internal/workload/doctor/suite_test.go new file mode 100644 index 000000000..d693348b5 --- /dev/null +++ b/internal/workload/doctor/suite_test.go @@ -0,0 +1,178 @@ +// 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 doctor + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + + core "github.com/datarobot/cli/internal/doctor" + "github.com/datarobot/cli/internal/workload/sync" + "github.com/datarobot/cli/internal/workload/wapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// healthyProject writes a fully healthy local state: valid config with both +// pointers, valid manifest agreeing with it, no rollback tree, no lock file. +func healthyProject(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig(testCatalogID, testVersionID))) + + require.NoError(t, wapi.SaveManifest(dir, validManifest(testVersionID))) + + return dir +} + +func TestLocalChecks_FixedOrder(t *testing.T) { + ids := make([]string, 0, 6) + + for _, c := range LocalChecks(t.TempDir()) { + ids = append(ids, c.ID()) + } + + assert.Equal(t, []string{ + CheckIDPresence, + CheckIDConfig, + CheckIDManifest, + CheckIDDivergence, + CheckIDRollback, + CheckIDLock, + }, ids) +} + +func TestLocalChecks_Healthy_AllOK(t *testing.T) { + results := runLocalChecks(t, healthyProject(t)) + + want := []core.Status{ + core.StatusOK, + core.StatusOK, + core.StatusOK, + core.StatusOK, + core.StatusOK, + core.StatusOK, + } + + for i, res := range results { + assert.Equal(t, want[i], res.Status, "check %s: summary %q", res.CheckID, res.Summary) + } +} + +func TestLocalChecks_Cascade_PresenceFailSkipsEverything(t *testing.T) { + // Fresh dir: nothing linked, so every other check must SKIP. + results := runLocalChecks(t, t.TempDir()) + + require.Len(t, results, 6) + + assert.Equal(t, core.StatusFAIL, results[0].Status) + + for _, res := range results[1:] { + assert.Equal(t, core.StatusSKIP, res.Status, "check %s should SKIP", res.CheckID) + + assert.Contains(t, res.Summary, "no linked state") + } +} + +func TestLocalChecks_Cascade_ConfigFailSkipsDivergenceOnly(t *testing.T) { + dir := t.TempDir() + + // State dir present (presence OK), no config, valid manifest: + // divergence SKIPs but manifest/rollback/lock still run. + initStateDir(t, dir) + + require.NoError(t, wapi.SaveManifest(dir, validManifest(""))) + + results := runLocalChecks(t, dir) + + require.Len(t, results, 6) + + want := []core.Status{ + core.StatusOK, // presence + core.StatusFAIL, // config missing + core.StatusOK, // manifest still runs + core.StatusSKIP, // divergence depends on config + core.StatusOK, // rollback still runs + core.StatusOK, // lock still runs + } + + for i, res := range results { + assert.Equal(t, want[i], res.Status, "check %s: summary %q", res.CheckID, res.Summary) + } +} + +func TestLocalChecks_Cascade_ManifestFailSkipsDivergenceOnly(t *testing.T) { + dir := t.TempDir() + + // Valid config, corrupt manifest: config stays OK, divergence SKIPs, + // rollback/lock still run. + initStateDir(t, dir) + + require.NoError(t, wapi.SaveConfig(dir, validConfig(testCatalogID, testVersionID))) + + writeStateFile(t, dir, "manifest.json", `{"version":1,`) + + results := runLocalChecks(t, dir) + + require.Len(t, results, 6) + + want := []core.Status{ + core.StatusOK, // presence + core.StatusOK, // config + core.StatusFAIL, // manifest corrupt + core.StatusSKIP, // divergence depends on manifest + core.StatusOK, // rollback still runs + core.StatusOK, // lock still runs + } + + for i, res := range results { + assert.Equal(t, want[i], res.Status, "check %s: summary %q", res.CheckID, res.Summary) + } +} + +func TestLocalChecks_ReadOnly_ZeroWritesAndNoNewFiles(t *testing.T) { + t.Run("state byte-identical after a full run", func(t *testing.T) { + dir := healthyProject(t) + + // A stale rollback tree must survive a read-only diagnosis untouched. + backedUp := filepath.Join(wapi.Dir(dir), wapi.RollbackDirName, "app", "main.go") + + require.NoError(t, os.MkdirAll(filepath.Dir(backedUp), 0o755)) + + require.NoError(t, os.WriteFile(backedUp, []byte("package main\n"), 0o600)) + + before := stateFileHashes(t, dir) + + runLocalChecks(t, dir) + + assert.Equal(t, before, stateFileHashes(t, dir)) + }) + + t.Run("sync.lock is not created", func(t *testing.T) { + dir := healthyProject(t) + + runLocalChecks(t, dir) + + _, err := os.Stat(filepath.Join(wapi.Dir(dir), sync.LockFileName)) + + assert.ErrorIs(t, err, fs.ErrNotExist, "read-only run must not create sync.lock") + }) +} diff --git a/internal/workload/sync/synclock.go b/internal/workload/sync/synclock.go index 63cce0d9a..b8e883d44 100644 --- a/internal/workload/sync/synclock.go +++ b/internal/workload/sync/synclock.go @@ -22,7 +22,10 @@ import ( "github.com/datarobot/cli/internal/workload/wapi" ) -const syncLockFile = "sync.lock" +// LockFileName is the advisory lock file the sync engine creates inside the +// state directory. Exported so read-only consumers (e.g. the doctor's +// non-creating lock probe) can locate the file without duplicating the name. +const LockFileName = "sync.lock" // SyncLock is the platform-specific exclusive lock for a sync run. type SyncLock struct { @@ -40,7 +43,7 @@ func AcquireSyncLock(projectDir string) (*SyncLock, error) { return nil, fmt.Errorf("acquire sync lock: %w", err) } - path := filepath.Join(stateDir, syncLockFile) + path := filepath.Join(stateDir, LockFileName) f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) if err != nil { diff --git a/internal/workload/wapi/paths.go b/internal/workload/wapi/paths.go index 4273c8867..ef4c87d1a 100644 --- a/internal/workload/wapi/paths.go +++ b/internal/workload/wapi/paths.go @@ -108,6 +108,12 @@ func ConfigPath(projectDir string) string { return configPath(projectDir) } +// ManifestPath is the project's manifest.json, exported for the same reason +// as ConfigPath: diagnostics should name the real file, not re-derive it. +func ManifestPath(projectDir string) string { + return manifestPath(projectDir) +} + func configPath(projectDir string) string { return filepath.Join(Dir(projectDir), configFile) }