From c17a6c3d817c4d9355a8eae8b90218bc5c5eabc8 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Thu, 27 Aug 2026 19:18:35 -0700 Subject: [PATCH 01/14] [RAPTOR-18075] feat(artifact): add generic doctor framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add internal/doctor: a state-agnostic check-and-report framework that a future top-level `dr doctor` can reuse. No wapi/sync/workload imports. - Status enum (OK/WARN/FAIL/SKIP), Result{CheckID, Status, Summary, Remedy, Details, Fixable}, Check interface (ID, Name, Run(ctx)). - Runner executes checks in caller order, stamps CheckIDs, and Report derives counts, the lowercase ok|warn|fail verdict, and the exit code (1 iff any FAIL). - Text reporter: header (project dir + artifact or "not linked"), CHECK/STATUS/DETAIL lipgloss table with tui.TableBorderStyle, remedies for non-OK rows, summary line with counts + verdict. - JSON reporter: single pure-JSON object with the pinned schema — absolute projectDir, artifactId null when unlinked (empty string normalized), uppercase per-check status, checks in runner order, summary counts matching the tally, and an optional actions[] array (omitted entirely for read-only runs, present for repair runs). TDD with testify; 19 tests pass under -race; task lint clean on linux/darwin/windows. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/doctor/doctor.go | 111 +++++++++++ internal/doctor/doctor_test.go | 187 ++++++++++++++++++ internal/doctor/json.go | 78 ++++++++ internal/doctor/report.go | 90 +++++++++ internal/doctor/reporters_test.go | 317 ++++++++++++++++++++++++++++++ internal/doctor/runner.go | 91 +++++++++ internal/doctor/text.go | 149 ++++++++++++++ 7 files changed, 1023 insertions(+) create mode 100644 internal/doctor/doctor.go create mode 100644 internal/doctor/doctor_test.go create mode 100644 internal/doctor/json.go create mode 100644 internal/doctor/report.go create mode 100644 internal/doctor/reporters_test.go create mode 100644 internal/doctor/runner.go create mode 100644 internal/doctor/text.go diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go new file mode 100644 index 000000000..3c0ab9ecc --- /dev/null +++ b/internal/doctor/doctor.go @@ -0,0 +1,111 @@ +// 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. +// +// It defines a Check interface, a Result type, an ordered Runner, and two +// reporters (human-readable text and pure-JSON). It knows nothing about any +// specific state model: concrete checks (e.g. workload sync-state checks) +// live in their own packages and plug into the Runner. This keeps the layer +// reusable for a future top-level "dr doctor". +package doctor + +import "context" + +// Status is the outcome of a single check. Check-level statuses are rendered +// uppercase in both reporters; the run's overall verdict (derived from these) +// is rendered 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 checked for is broken; any FAIL makes + // the overall 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 individual checks do not need to set it. +type Result struct { + // CheckID is the stable namespaced identifier of the check (e.g. + // "wapi.config"). It 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, with Reason saying 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..2e1241287 --- /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, with a +// trailing newline). HTML escaping is disabled so remedy strings like +// "--relink " survive verbatim. For read-only runs the +// "actions" key is omitted entirely; 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..0c4f4a4cc --- /dev/null +++ b/internal/doctor/report.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 + +// 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 + // entirely). For repair runs (--fix/--relink) 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. The counts always equal the +// per-check tally of Checks. +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-string ids are treated as 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..320b83b75 --- /dev/null +++ b/internal/doctor/reporters_test.go @@ -0,0 +1,317 @@ +// 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 " + + 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 ") + + // 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)) + + 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 ", 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..2d06945ca --- /dev/null +++ b/internal/doctor/runner.go @@ -0,0 +1,91 @@ +// 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 even if a check forgets to set it. +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 from its checks: +// "fail" if any check FAILed, else "warn" if any WARNed, else "ok". +// A run with only SKIPs 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..b7331480c --- /dev/null +++ b/internal/doctor/text.go @@ -0,0 +1,149 @@ +// 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" + } + + fmt.Fprintf(w, "Doctor report for %s — artifact: %s\n\n", report.ProjectDir, artifact) + + if err := writeChecksTable(w, report); err != nil { + return err + } + + writeRemedies(w, report) + + writeSummary(w, report) + + return nil +} + +// 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) { + 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 + } + + fmt.Fprintln(w, "\nRemedies") + + for _, r := range remedies { + fmt.Fprintln(w, r) + } +} + +// writeSummary prints the per-status counts and the overall verdict. +func writeSummary(w io.Writer, report Report) { + counts := report.Counts() + + 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()) +} From 649446e66fe49db0e458bdbd413b0127f44c2355 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Thu, 27 Aug 2026 19:28:47 -0700 Subject: [PATCH 02/14] [RAPTOR-18075] feat(artifact): add doctor local checks for wapi sync state Add internal/workload/doctor: the six read-only local checks for `dr artifact code doctor`, built on the generic internal/doctor framework and the existing wapi/sync primitives. Checks perform zero local writes and zero network calls; repairs remain behind --fix/--relink (later milestone). Checks (pinned fixed order): - wapi.presence: state dir exists at current or legacy location - wapi.config: LoadConfig; missing/corrupt/semantic FAIL carries the absolute path in details.path - wapi.manifest: LoadManifest; same FAIL semantics, independent of config - wapi.config-manifest-divergence: config lastSyncedVersionId vs manifest syncedVersionId incl. nil-ness and empty->nil normalization - wapi.rollback: stale .rollback/ tree at current or legacy location, empty dir included - wapi.lock: NON-CREATING probe (open without O_CREATE + non-blocking exclusive flock): absent -> OK, acquirable -> OK (released within Run), held -> FAIL, permission/IO open error -> WARN "cannot inspect" (never misreported as held), Windows -> SKIP via an injected goos seam SKIP cascades: presence FAIL skips everything; config FAIL skips divergence (remote checks will skip too once they exist) while manifest/rollback/lock still run; manifest FAIL skips divergence only. Canonical remedy strings live in remedies.go and are shared by both reporters. Two small exported helpers added to existing packages: wapi.ManifestPath and sync.LockFileName. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/workload/doctor/config.go | 66 +++++++ internal/workload/doctor/config_test.go | 105 ++++++++++ internal/workload/doctor/divergence.go | 97 +++++++++ internal/workload/doctor/divergence_test.go | 102 ++++++++++ internal/workload/doctor/doc.go | 24 +++ internal/workload/doctor/helpers_test.go | 187 ++++++++++++++++++ internal/workload/doctor/local.go | 142 +++++++++++++ internal/workload/doctor/lock.go | 137 +++++++++++++ internal/workload/doctor/lock_test.go | 175 ++++++++++++++++ internal/workload/doctor/lockprobe_unix.go | 34 ++++ internal/workload/doctor/lockprobe_windows.go | 32 +++ internal/workload/doctor/manifest.go | 67 +++++++ internal/workload/doctor/manifest_test.go | 145 ++++++++++++++ internal/workload/doctor/presence.go | 55 ++++++ internal/workload/doctor/presence_test.go | 80 ++++++++ internal/workload/doctor/remedies.go | 50 +++++ internal/workload/doctor/rollback.go | 63 ++++++ internal/workload/doctor/rollback_test.go | 107 ++++++++++ internal/workload/doctor/suite_test.go | 178 +++++++++++++++++ internal/workload/sync/synclock.go | 7 +- internal/workload/wapi/paths.go | 6 + 21 files changed, 1857 insertions(+), 2 deletions(-) create mode 100644 internal/workload/doctor/config.go create mode 100644 internal/workload/doctor/config_test.go create mode 100644 internal/workload/doctor/divergence.go create mode 100644 internal/workload/doctor/divergence_test.go create mode 100644 internal/workload/doctor/doc.go create mode 100644 internal/workload/doctor/helpers_test.go create mode 100644 internal/workload/doctor/local.go create mode 100644 internal/workload/doctor/lock.go create mode 100644 internal/workload/doctor/lock_test.go create mode 100644 internal/workload/doctor/lockprobe_unix.go create mode 100644 internal/workload/doctor/lockprobe_windows.go create mode 100644 internal/workload/doctor/manifest.go create mode 100644 internal/workload/doctor/manifest_test.go create mode 100644 internal/workload/doctor/presence.go create mode 100644 internal/workload/doctor/presence_test.go create mode 100644 internal/workload/doctor/remedies.go create mode 100644 internal/workload/doctor/rollback.go create mode 100644 internal/workload/doctor/rollback_test.go create mode 100644 internal/workload/doctor/suite_test.go 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/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..1e058a789 --- /dev/null +++ b/internal/workload/doctor/local.go @@ -0,0 +1,142 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package 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" +) + +// LocalChecks returns the six local sync-state checks in the fixed report +// order: presence, config, manifest, divergence, rollback, lock. The remote +// checks (defined by their own feature) 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 +// reporting 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 file path appears both in the human-readable summary +// and in 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 own Error text +// already embeds the path, which corruptFileResult appends once), or the +// error itself for anything else. +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, where 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..959610615 --- /dev/null +++ b/internal/workload/doctor/lock.go @@ -0,0 +1,137 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +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 &lockCheck{projectDir: projectDir, goos: runtime.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/remedies.go b/internal/workload/doctor/remedies.go new file mode 100644 index 000000000..d5d5a2fdd --- /dev/null +++ b/internal/workload/doctor/remedies.go @@ -0,0 +1,50 @@ +// 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" +) 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) } From d932c926fc94a687411a1a392867e31eb7802343 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Thu, 27 Aug 2026 19:38:40 -0700 Subject: [PATCH 03/14] [RAPTOR-18075] feat(artifact): wire `dr artifact code doctor` command Register the doctor command under `artifact code` (inheriting the artifact tree's DATAROBOT_CLI_FEATURE_WORKLOAD gate) and run the six local sync-state checks through the generic framework Runner. - Flags: --dir (default ".", resolved via filepath.Abs with final-component symlink resolution, never prompts), --output-format (text|json), and --yes/-y read from cobra with the DATAROBOT_CLI_NON_INTERACTIVE env var bound via viperx.BindEnv only. - Soft auth probe inside RunE: remote credentials are resolved from the env pair or the stored drconfig profile without prompting, without calling auth.EnsureAuthenticatedE (no login wizard), and without ever writing drconfig.yaml. Local checks run regardless. - Exit 1 iff any check FAILs, via cli.ErrSilent with runtime-set SilenceErrors so the rendered report is not followed by a cobra error echo; usage errors keep their explanatory message. - Text and JSON reporters per the pinned output contract; read-only runs write nothing (verified: state byte-identical, sync.lock never created). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/artifact/cmd_test.go | 69 ++++ cmd/artifact/code/cmd.go | 4 + cmd/artifact/code/cmd_test.go | 33 ++ cmd/artifact/code/doctor/cmd.go | 229 +++++++++++++ cmd/artifact/code/doctor/cmd_test.go | 475 +++++++++++++++++++++++++++ 5 files changed, 810 insertions(+) create mode 100644 cmd/artifact/cmd_test.go create mode 100644 cmd/artifact/code/cmd_test.go create mode 100644 cmd/artifact/code/doctor/cmd.go create mode 100644 cmd/artifact/code/doctor/cmd_test.go 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..00fea78be --- /dev/null +++ b/cmd/artifact/code/doctor/cmd.go @@ -0,0 +1,229 @@ +// 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 ( + "fmt" + "path/filepath" + + "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/outputformat" + "github.com/datarobot/cli/internal/telemetry" + 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") +} + +// 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. + +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 --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 runDoctor(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).") + + telemetry.TrackWith(c, func(cmd *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "yes": cli.IsNonInteractive(cmd), + "output_format": string(outputFormat), + } + }) + + return c +} + +// runDoctor executes one diagnosis: resolve the project directory, run the +// check suite, render the report, and exit 1 iff any check FAILed. 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 runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error { + dirFlag, _ := cmd.Flags().GetString("dir") + + projectDir, err := resolveProjectDir(dirFlag) + if err != nil { + return err + } + + // 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 six local checks in the pinned fixed order. Remote checks append + // after these once their feature lands (fixed order contract). + results := core.NewRunner(wldoctor.LocalChecks(projectDir)...).Run(cmd.Context()) + + report := core.NewReport(projectDir, linkedArtifactID(projectDir), results) + + out := cmd.OutOrStdout() + + if outputFormat == outputformat.OutputFormatJSON { + err = core.WriteJSON(out, report) + } else { + err = core.WriteText(out, report) + } + + if err != nil { + return err + } + + if report.ExitCode() == 1 { + // The report is already rendered on stdout; silence cobra's error + // echo and exit 1 via the sentinel (main maps any RunE error to 1). + // Per docs/development/telemetry.md, a command returning + // cli.ErrSilent must carry SilenceErrors — set here so flag/usage + // errors keep their explanatory message. + cmd.SilenceErrors = true + + return cli.ErrSilent + } + + return nil +} + +// 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. +func resolveProjectDir(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve project directory: %w", err) + } + + resolved, linkErr := filepath.EvalSymlinks(abs) + if linkErr != nil || filepath.Base(resolved) == filepath.Base(abs) { + // A missing or unreadable path keeps the Abs result — the checks + // report the real condition. An unchanged final component means the + // path itself is not a symlink. + 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 +} diff --git a/cmd/artifact/code/doctor/cmd_test.go b/cmd/artifact/code/doctor/cmd_test.go new file mode 100644 index 000000000..c0796b158 --- /dev/null +++ b/cmd/artifact/code/doctor/cmd_test.go @@ -0,0 +1,475 @@ +// 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" + "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/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 local-check order the command must preserve. +var pinnedCheckOrder = []string{ + "wapi.presence", + "wapi.config", + "wapi.manifest", + "wapi.config-manifest-divergence", + "wapi.rollback", + "wapi.lock", +} + +// 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) + + 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: 6 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) + + 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: 6}, 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") +} + +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 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) + }) +} From 33af183228050351678adce7ea9cdc130176f640 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Thu, 27 Aug 2026 20:22:29 -0700 Subject: [PATCH 04/14] [RAPTOR-18075] feat(artifact): add doctor remote checks against the live artifact Add the four remote checks to internal/workload/doctor and wire them into `dr artifact code doctor`, keeping the pinned 6-local-then-4-remote order: - remote.artifact-exists: 404 -> FAIL "linked artifact not found (deleted?)" with the `doctor --relink` remedy; any other fetch failure -> SKIP. - remote.artifact-locked: locked -> WARN (sync execute refused, preview allowed), fixable=false, never FAIL. - remote.catalog-mismatch: config.CatalogID vs artifact codeRef.CatalogID, FAIL on mismatch (either side absent counts as divergent), both-absent OK. - remote.drift: codeRef.CatalogVersionID vs config.LastSyncedVersionID, WARN on drift with a `sync --dry-run` remedy, no baseline -> OK. All four share ONE artifact snapshot per run through a lazy remoteSnapshot backed by an injected ArtifactGetter seam (production: workload.GetArtifact; tests: fake store with call-count assertion), so a run performs exactly one GetArtifact and a vanished artifact collapses the dependent checks to SKIP. Remote error mapping is pinned: 404 -> FAIL-as-deleted on artifact-exists only; ANY other failure (401/403, 5xx, timeout, unreachable, unauthenticated) -> SKIP with a `dr auth login`/connectivity remedy that never mentions --relink. Empty-string codeRef fields normalize to nil on both sides. The checks stay pure diagnostics: zero local writes, zero server writes. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/artifact/code/doctor/cmd.go | 14 +- cmd/artifact/code/doctor/cmd_test.go | 213 +++++++++++- internal/workload/doctor/local.go | 12 + internal/workload/doctor/remedies.go | 24 ++ internal/workload/doctor/remote.go | 425 ++++++++++++++++++++++++ internal/workload/doctor/remote_test.go | 414 +++++++++++++++++++++++ 6 files changed, 1096 insertions(+), 6 deletions(-) create mode 100644 internal/workload/doctor/remote.go create mode 100644 internal/workload/doctor/remote_test.go diff --git a/cmd/artifact/code/doctor/cmd.go b/cmd/artifact/code/doctor/cmd.go index 00fea78be..06b4c7f17 100644 --- a/cmd/artifact/code/doctor/cmd.go +++ b/cmd/artifact/code/doctor/cmd.go @@ -31,6 +31,7 @@ import ( "github.com/datarobot/cli/internal/log" "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" @@ -42,6 +43,10 @@ func init() { _ = 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 { @@ -123,9 +128,12 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error log.Debug("doctor found no remote credentials; remote checks will report SKIP") } - // The six local checks in the pinned fixed order. Remote checks append - // after these once their feature lands (fixed order contract). - results := core.NewRunner(wldoctor.LocalChecks(projectDir)...).Run(cmd.Context()) + // 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. + results := core.NewRunner( + wldoctor.Checks(projectDir, wldoctor.ArtifactGetterFunc(getArtifactFn))..., + ).Run(cmd.Context()) report := core.NewReport(projectDir, linkedArtifactID(projectDir), results) diff --git a/cmd/artifact/code/doctor/cmd_test.go b/cmd/artifact/code/doctor/cmd_test.go index c0796b158..a34df2c8f 100644 --- a/cmd/artifact/code/doctor/cmd_test.go +++ b/cmd/artifact/code/doctor/cmd_test.go @@ -19,6 +19,8 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" + "fmt" "io" "io/fs" "os" @@ -29,6 +31,8 @@ import ( "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" @@ -70,7 +74,8 @@ type jsonReport struct { Summary jsonSummary `json:"summary"` } -// pinnedCheckOrder is the fixed local-check order the command must preserve. +// 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", @@ -78,6 +83,54 @@ var pinnedCheckOrder = []string{ "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 @@ -198,6 +251,12 @@ func TestRunE_HealthyProject_TextReport_ExitZero(t *testing.T) { 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) @@ -213,7 +272,7 @@ func TestRunE_HealthyProject_TextReport_ExitZero(t *testing.T) { assert.Contains(t, outStr, id, "renders check row %s", id) } - assert.Contains(t, outStr, "Summary: 6 ok, 0 warn, 0 fail, 0 skip — verdict: ok") + assert.Contains(t, outStr, "Summary: 10 ok, 0 warn, 0 fail, 0 skip — verdict: ok") } func TestRunE_UnlinkedProject_RendersReport_ExitsOneSilently(t *testing.T) { @@ -236,6 +295,10 @@ func TestRunE_HealthyProject_JSONReport(t *testing.T) { 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) @@ -258,7 +321,7 @@ func TestRunE_HealthyProject_JSONReport(t *testing.T) { } assert.Equal(t, pinnedCheckOrder, gotOrder) - assert.Equal(t, jsonSummary{OK: 6}, report.Summary) + assert.Equal(t, jsonSummary{OK: 10}, report.Summary) } func TestRunE_JSONOutput_CorruptConfig_FailWithPath(t *testing.T) { @@ -425,6 +488,150 @@ func TestCmd_RejectsPositionalArgs(t *testing.T) { 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. diff --git a/internal/workload/doctor/local.go b/internal/workload/doctor/local.go index 1e058a789..eff8ac055 100644 --- a/internal/workload/doctor/local.go +++ b/internal/workload/doctor/local.go @@ -34,6 +34,18 @@ const ( CheckIDLock = "wapi.lock" ) +// Checks returns the complete doctor check suite in the pinned fixed order: +// the six local checks followed by the four remote checks (ten total in +// ticket scope; any 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 the fixed report // order: presence, config, manifest, divergence, rollback, lock. The remote // checks (defined by their own feature) append after these. diff --git a/internal/workload/doctor/remedies.go b/internal/workload/doctor/remedies.go index d5d5a2fdd..a64e30d9b 100644 --- a/internal/workload/doctor/remedies.go +++ b/internal/workload/doctor/remedies.go @@ -47,4 +47,28 @@ const ( // 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..0246a7a6a --- /dev/null +++ b/internal/workload/doctor/remote.go @@ -0,0 +1,425 @@ +// 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 (which delegates 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 the fixed report +// order: artifact-exists, artifact-locked, catalog-mismatch, drift. All four +// share the single artifact snapshot fetched through store (exactly one +// GetArtifact per doctor 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. +func isNotFound(err error) bool { + var httpErr *drapi.HTTPError + + return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound +} + +// 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..d25a5e454 --- /dev/null +++ b/internal/workload/doctor/remote_test.go @@ -0,0 +1,414 @@ +// 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 point assertions. +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() From 658382fc87bea5e00a221a3de98cc6adddffc7df Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Thu, 27 Aug 2026 23:19:11 -0700 Subject: [PATCH 05/14] [RAPTOR-18075] feat(artifact): add doctor --fix safe auto-repairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement `dr artifact code doctor --fix`: safe local-only auto-repairs with a global safety gate. 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 reason "sync in progress" — a sync writes manifest.json in its final phase, so no repair is safe underneath it. Three repairs, each reported as an actions[] entry (performed|skipped-with-reason|not-needed) in both text and JSON: 1. Rebuild manifest from config — Manifest{Version:1, SyncedAt nil-iff-version-nil, SyncedVersionID: cfg.LastSyncedVersionID, Files:{}}. Requires a valid config; corrupt config skips with a re-init remedy. 2. Clear interrupted rollback via sync.RestoreStaleIfPresent — restores backed-up files to the working tree, removes .rollback/. 3. Clear lock only if acquirable — AcquireSyncLock then release; absent lock file reports not-needed (never created). A repair failing mid-write is reported skipped with the error as reason; remaining repairs still attempt. --fix re-runs the full check suite and reports post-fix state; exit code reflects POST-fix state. No-op on a healthy project with explicit "nothing to fix" output. --fix never touches the server. --fix and --relink are mutually exclusive (usage error, exit 1, no checks run). New files: - internal/workload/doctor/fix.go: RunFix repair suite + three repair ops - internal/workload/doctor/fix_test.go: 20 unit tests covering all VAL-FIX scenarios (healthy no-op, missing/corrupt/divergent manifest, corrupt config, rollback restore, lock safety matrix, held lock gate, partial failure, working-tree preservation, windows gate, multiple problems in one run) - cmd/artifact/code/doctor/fix_cmd_test.go: command-level tests for fix flows (healthy no-op, missing manifest, corrupt config, held lock, rollback restore, idempotent second run, mutual exclusion, deleted artifact + missing manifest composition) - cmd/artifact/code/doctor/heldlock_{unix,windows}_test.go: platform- specific held-lock helpers for command-level tests Modified files: - cmd/artifact/code/doctor/cmd.go: --fix flag, mutual-exclusion check, fix-then-rerun wiring, actions in report - internal/doctor/text.go: writeActions section in text reporter - internal/workload/doctor/lock.go: extracted newLockCheckWithGoos for fix's gate probe reuse Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/artifact/code/doctor/cmd.go | 42 +- cmd/artifact/code/doctor/fix_cmd_test.go | 398 +++++++++++ .../code/doctor/heldlock_unix_test.go | 45 ++ .../code/doctor/heldlock_windows_test.go | 26 + internal/doctor/text.go | 42 ++ internal/workload/doctor/fix.go | 253 +++++++ internal/workload/doctor/fix_test.go | 653 ++++++++++++++++++ internal/workload/doctor/lock.go | 9 +- 8 files changed, 1463 insertions(+), 5 deletions(-) create mode 100644 cmd/artifact/code/doctor/fix_cmd_test.go create mode 100644 cmd/artifact/code/doctor/heldlock_unix_test.go create mode 100644 cmd/artifact/code/doctor/heldlock_windows_test.go create mode 100644 internal/workload/doctor/fix.go create mode 100644 internal/workload/doctor/fix_test.go diff --git a/cmd/artifact/code/doctor/cmd.go b/cmd/artifact/code/doctor/cmd.go index 06b4c7f17..da583e946 100644 --- a/cmd/artifact/code/doctor/cmd.go +++ b/cmd/artifact/code/doctor/cmd.go @@ -20,6 +20,7 @@ package doctor import ( + "errors" "fmt" "path/filepath" @@ -67,6 +68,12 @@ 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. + 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. @@ -74,6 +81,7 @@ report on stdout. Example: dr artifact code doctor dr artifact code doctor --dir ./service + dr artifact code doctor --fix 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 @@ -94,6 +102,10 @@ Example: // 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.") + telemetry.TrackWith(c, func(cmd *cobra.Command, _ []string) map[string]any { return map[string]any{ "yes": cli.IsNonInteractive(cmd), @@ -105,10 +117,21 @@ Example: } // runDoctor executes one diagnosis: resolve the project directory, run the -// check suite, render the report, and exit 1 iff any check FAILed. 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. +// 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. 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 runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error { + fix, _ := cmd.Flags().GetBool("fix") + + // --fix and --relink are mutually exclusive: a usage error exits 1 + // before any check runs. The relink flag lands with the relink feature; + // Flags().Changed reports false until it is registered. + if fix && cmd.Flags().Changed("relink") { + return errors.New("--fix and --relink are mutually exclusive; use one or the other") + } + dirFlag, _ := cmd.Flags().GetString("dir") projectDir, err := resolveProjectDir(dirFlag) @@ -116,6 +139,14 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error return err } + var actions *[]core.Action + + if fix { + performed := wldoctor.RunFix(cmd.Context(), projectDir) + + actions = &performed + } + // 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 @@ -130,13 +161,16 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error // 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. + // fetch through the getArtifactFn seam. After --fix this is the post-fix + // 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 + out := cmd.OutOrStdout() if outputFormat == outputformat.OutputFormatJSON { 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..e74caf1d8 --- /dev/null +++ b/cmd/artifact/code/doctor/fix_cmd_test.go @@ -0,0 +1,398 @@ +// 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 covers VAL-FIX-001: --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 covers VAL-FIX-002, +// VAL-FIX-013 and VAL-FIX-014 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 covers VAL-FIX-005 and +// VAL-FIX-015: 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 covers VAL-FIX-008, VAL-FIX-018 +// and VAL-CROSS-014 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 covers VAL-FIX-006 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 covers VAL-FIX-007 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 covers VAL-FIX-012 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 covers the usage-error rule: --fix +// and --relink cannot be combined — the run errors with exit 1 and no checks +// run (the remote artifact seam is never called, stdout stays empty). +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, _ := newTestCmd(t, "--dir", tmp, "--fix", "--relink", "abc123") + + require.Error(t, c.Execute(), "combining --fix and --relink must be a usage error") + + 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 +// covers VAL-FIX-020: 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/internal/doctor/text.go b/internal/doctor/text.go index b7331480c..2b1843cf7 100644 --- a/internal/doctor/text.go +++ b/internal/doctor/text.go @@ -44,6 +44,8 @@ func WriteText(w io.Writer, report Report) error { writeRemedies(w, report) + writeActions(w, report) + writeSummary(w, report) return nil @@ -140,6 +142,46 @@ func writeRemedies(w io.Writer, report Report) { } } +// 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: a --fix on a healthy +// project is a no-op and the output must state that unambiguously. +func writeActions(w io.Writer, report Report) { + if report.Actions == nil { + return + } + + actions := *report.Actions + + fmt.Fprintln(w, "\nRepairs") + + if len(actions) == 0 { + fmt.Fprintln(w, " nothing to fix: no repairs needed") + + return + } + + 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 + } + + fmt.Fprintln(w, line) + } + + if allNotNeeded { + fmt.Fprintln(w, " nothing to fix: no repairs needed") + } +} + // writeSummary prints the per-status counts and the overall verdict. func writeSummary(w io.Writer, report Report) { counts := report.Counts() diff --git a/internal/workload/doctor/fix.go b/internal/workload/doctor/fix.go new file mode 100644 index 000000000..4fe2e5884 --- /dev/null +++ b/internal/workload/doctor/fix.go @@ -0,0 +1,253 @@ +// 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 — 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. +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} +} diff --git a/internal/workload/doctor/fix_test.go b/internal/workload/doctor/fix_test.go new file mode 100644 index 000000000..1c6766dcd --- /dev/null +++ b/internal/workload/doctor/fix_test.go @@ -0,0 +1,653 @@ +// 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 for assertions. +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 covers VAL-FIX-001/VAL-FIX-012: 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 covers VAL-FIX-002: 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 covers VAL-FIX-003: 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 covers VAL-FIX-004: 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 covers VAL-FIX-005: +// without a valid config the manifest cannot be rebuilt; 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 covers VAL-FIX-006: 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 covers VAL-FIX-007: 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 covers VAL-FIX-010: 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 covers VAL-FIX-009: 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) + + assert.Equal(t, core.ActionNotNeeded, actionByID(t, actions)[CheckIDLock].Status) + + // 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 covers VAL-FIX-008 and +// VAL-CROSS-014: 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 covers VAL-FIX-019: 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 covers VAL-FIX-017: 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 covers VAL-FIX-011: +// 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/lock.go b/internal/workload/doctor/lock.go index 959610615..a42362ee3 100644 --- a/internal/workload/doctor/lock.go +++ b/internal/workload/doctor/lock.go @@ -45,7 +45,14 @@ type lockCheck struct { // newLockCheck builds the lock check with the real host platform. func newLockCheck(projectDir string) *lockCheck { - return &lockCheck{projectDir: projectDir, goos: runtime.GOOS} + 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 { From a1c461b584d00c7a96afe03cfbf1f33b0586a8f7 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Fri, 28 Aug 2026 00:19:43 -0700 Subject: [PATCH 06/14] [RAPTOR-18075] feat(artifact): add doctor --relink in-place repoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement `dr artifact code doctor --relink `: repoints the project at a new artifact with a fresh sync baseline reset, without deleting the state directory. Safety gates (every abort leaves state byte-identical): - Lock held by live process → abort "sync in progress" - Not-linked project → error pointing to init (short-circuits before fetch) - API unreachable/unauthenticated → error abort (relink hard-requires API) - Target 404 → abort - Target locked → abort (cannot sync to a locked artifact) - Target Artifact.Type != "service" → abort (cross-type lineage refused) - Same-id relink → allowed, warned, BASE reset Confirm prompt defaults to No (bespoke [y/N] where empty Enter declines; NOT reader.AskYesNo). Non-interactive (--yes or non-TTY) prints the warning to stderr and proceeds. Decline/Ctrl-C/EOF aborts with state untouched. On confirm: config rewritten (artifactId=new, catalogId=new codeRef.CatalogID normalized empty→nil, lastSyncedVersionId=null), manifest reset to empty BASE, history.log appended {op:relink, from, to, ts}, working tree untouched, zero server writes. Post-relink checks re-run and report; actions[] included. --fix and --relink are mutually exclusive (cobra MarkFlagsMutuallyExclusive plus belt-and-suspenders guard). 17 unit tests in internal/workload/doctor/relink_test.go cover all gates and the happy path. 16 command-level tests in cmd/artifact/code/doctor/relink_cmd_test.go cover the CLI surface (JSON purity, actions array, exit codes, flag shapes). Manually verified end-to-end on staging: create A → init → delete A → doctor FAILs with relink remedy → --relink B → doctor healthy (all 10 OK) → sync targets B (acceptance criterion #1) → second sync no-op → clean up both fixtures (sweep confirms 0 doctor-test-* remaining). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/artifact/code/doctor/cmd.go | 164 ++++- cmd/artifact/code/doctor/relink_cmd_test.go | 588 ++++++++++++++++ internal/workload/doctor/relink.go | 288 ++++++++ internal/workload/doctor/relink_test.go | 716 ++++++++++++++++++++ 4 files changed, 1727 insertions(+), 29 deletions(-) create mode 100644 cmd/artifact/code/doctor/relink_cmd_test.go create mode 100644 internal/workload/doctor/relink.go create mode 100644 internal/workload/doctor/relink_test.go diff --git a/cmd/artifact/code/doctor/cmd.go b/cmd/artifact/code/doctor/cmd.go index da583e946..3f76f16c5 100644 --- a/cmd/artifact/code/doctor/cmd.go +++ b/cmd/artifact/code/doctor/cmd.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "path/filepath" + "strings" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/cli" @@ -30,6 +31,7 @@ import ( "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" @@ -74,6 +76,12 @@ 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. @@ -82,6 +90,7 @@ 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 @@ -106,10 +115,19 @@ Example: "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), } }) @@ -119,15 +137,19 @@ Example: // runDoctor 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. 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. +// 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 runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error { fix, _ := cmd.Flags().GetBool("fix") - // --fix and --relink are mutually exclusive: a usage error exits 1 - // before any check runs. The relink flag lands with the relink feature; - // Flags().Changed reports false until it is registered. + relinkID, _ := cmd.Flags().GetString("relink") + + // --fix and --relink are mutually exclusive: cobra's + // MarkFlagsMutuallyExclusive handles the usage error, but the explicit + // check stays as a belt-and-suspenders guard (and gives a clearer + // message than cobra's generic one). if fix && cmd.Flags().Changed("relink") { return errors.New("--fix and --relink are mutually exclusive; use one or the other") } @@ -139,13 +161,7 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error return err } - var actions *[]core.Action - - if fix { - performed := wldoctor.RunFix(cmd.Context(), projectDir) - - actions = &performed - } + 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 @@ -161,8 +177,9 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error // 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 this is the post-fix - // state, so both the report and the exit code describe what remains. + // 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()) @@ -171,24 +188,18 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error report.Actions = actions - out := cmd.OutOrStdout() - - if outputFormat == outputformat.OutputFormatJSON { - err = core.WriteJSON(out, report) - } else { - err = core.WriteText(out, report) + if err := renderReport(cmd, outputFormat, report); err != nil { + return err } - if 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 report.ExitCode() == 1 { - // The report is already rendered on stdout; silence cobra's error - // echo and exit 1 via the sentinel (main maps any RunE error to 1). - // Per docs/development/telemetry.md, a command returning - // cli.ErrSilent must carry SilenceErrors — set here so flag/usage - // errors keep their explanatory message. + if relinkErr != nil || report.ExitCode() == 1 { cmd.SilenceErrors = true return cli.ErrSilent @@ -197,6 +208,40 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error 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). +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 relinkID != "" { + 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 @@ -269,3 +314,64 @@ func softAuthProbe() (remoteCreds, bool) { return remoteCreds{Endpoint: endpoint, Token: token}, true } + +// runRelinkPhase executes the relink operation and returns the actions and +// error. Extracted from runDoctor 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/relink_cmd_test.go b/cmd/artifact/code/doctor/relink_cmd_test.go new file mode 100644 index 000000000..1c52c853d --- /dev/null +++ b/cmd/artifact/code/doctor/relink_cmd_test.go @@ -0,0 +1,588 @@ +// 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 covers VAL-RELINK-001 +// and VAL-RELINK-020 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 covers VAL-RELINK-004: +// --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 covers VAL-RELINK-004 +// 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 covers VAL-RELINK-006 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 covers VAL-RELINK-010 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 covers VAL-RELINK-009 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 covers VAL-RELINK-015: +// JSON mode has 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 covers VAL-RELINK-014: +// 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 covers VAL-RELINK-013: +// 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 covers VAL-RELINK-021 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 covers VAL-RELINK-023 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 covers VAL-OUTPUT-009(a): +// 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 covers VAL-OUTPUT-018: +// --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 +} diff --git a/internal/workload/doctor/relink.go b/internal/workload/doctor/relink.go new file mode 100644 index 000000000..4d37ddcee --- /dev/null +++ b/internal/workload/doctor/relink.go @@ -0,0 +1,288 @@ +// 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). +// 7. Same-id relink (target == currently linked) → allowed, warned, BASE reset. +// +// 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 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. + // Short-circuit before any network fetch (VAL-RELINK-010). + 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 + } + + // Gate 6: same-id relink → allowed, warned, BASE reset. + warning := relinkWarning(oldCfg.ArtifactID, opts.NewArtifactID) + + // Gate 7: confirm prompt (defaults to No; empty Enter declines). + if !opts.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. +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..7902f78ca --- /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 timestamp assertions. +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 covers VAL-RELINK-001: +// 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 covers VAL-RELINK-011: +// 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 covers VAL-RELINK-012 and VAL-RELINK-018: +// 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 covers VAL-RELINK-009: +// 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 covers VAL-RELINK-010: +// 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 covers VAL-RELINK-006: +// 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 covers VAL-RELINK-007: +// 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 covers VAL-RELINK-023: +// 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 covers VAL-RELINK-021: +// 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 covers VAL-RELINK-003: +// 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 covers VAL-RELINK-024: +// 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 +} From ecd6786d0a3745f8a4f0d9ae50e88618ba4d9f58 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Fri, 28 Aug 2026 09:06:41 -0700 Subject: [PATCH 07/14] [RAPTOR-18075] docs(artifact): document doctor architecture and check-authoring guide Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/development/doctor.md | 161 +++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/development/doctor.md diff --git a/docs/development/doctor.md b/docs/development/doctor.md new file mode 100644 index 000000000..b29abdb87 --- /dev/null +++ b/docs/development/doctor.md @@ -0,0 +1,161 @@ +# `dr artifact code doctor` — Architecture & Check-Authoring Guide + +Audience: CLI contributors, especially the workload team adding their own +checks in follow-up PRs. + +## Overview + +`dr artifact code doctor` is a read-only diagnostic for a project's +`.datarobot/workload/` (legacy `.wapi/`) sync state. It inspects the local +state — linked artifact, `config.json`/`manifest.json` health, config/manifest +agreement, interrupted rollbacks, and the sync lock — and (when credentials +resolve) the linked artifact's remote health, then reports each check as +`OK`, `WARN`, `FAIL`, or `SKIP` with a concrete remedy for anything that needs +attention. + +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["runDoctor"] + 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 the workload team: + +- **`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. + +The M4 extras branch (`aj/RAPTOR-18075-doctor-extras`, stacked off this +branch as a separate draft PR) is the concrete example of this extension +pattern: it adds five informational checks (`wapi.legacy-unmigrated`, +`wapi.drignore`, `wapi.history`, `remote.no-coderef`, `wapi.checkouts-orphaned`) +by following exactly the steps above. From 1cecc40f04fcb0e534f8d4b797f29b04887959b2 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Fri, 28 Aug 2026 09:22:06 -0700 Subject: [PATCH 08/14] [RAPTOR-18075] feat(artifact): init relink offer replaces delete advice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the "Delete %s to re-init." advice from all init branches. The already-linked check now fetches the linked artifact and branches: - Corrupt config (unreadable linked state): report unreadable, remedy names `doctor --fix`, never deletion. No fetch attempted. - Gone (404) or catalog mismatch: 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 `doctor --relink `. - Healthy (or non-404 error): keep abort behavior, point to `doctor` for diagnosis. No delete advice anywhere. JSON mode: the already-linked abort emits a single JSON object on stdout {status:error, error:already-linked, artifactId:, remedy:} with human text on stderr, exit 1. HTML escaping is disabled so remedy strings with survive verbatim (matching the doctor's JSON reporter). The interactive offer and confirm prompts are testable via package-level seams (offerRelinkFn, makeRelinkConfirmFn, isInteractiveFn). The relink reuses internal/workload/doctor.RunRelink with the same safety gates (lock probe, 404/locked/type checks, warn+confirm default-No). Fresh-init path is byte-identical (unchanged output, state files, history entry). Legacy .wapi/-only projects follow the same branches (init already calls EnsureMigrated first). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/artifact/code/init/cmd.go | 98 +++- cmd/artifact/code/init/cmd_test.go | 682 ++++++++++++++++++++++++- cmd/artifact/code/init/display.go | 103 +++- cmd/artifact/code/init/display_test.go | 142 ++++- cmd/artifact/code/init/relink.go | 236 +++++++++ 5 files changed, 1241 insertions(+), 20 deletions(-) create mode 100644 cmd/artifact/code/init/relink.go diff --git a/cmd/artifact/code/init/cmd.go b/cmd/artifact/code/init/cmd.go index 26b650c1f..54f63bc0a 100644 --- a/cmd/artifact/code/init/cmd.go +++ b/cmd/artifact/code/init/cmd.go @@ -105,7 +105,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 +123,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 +162,97 @@ 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. + const remedy = "dr artifact code doctor --fix" + + if outputFormat == outputformat.OutputFormatJSON { + renderAlreadyLinkedJSON(cmd.OutOrStdout(), nil, remedy) + + fmt.Fprintln(stderr, "Project is already linked but the config is unreadable.") + fmt.Fprintln(stderr, "Run 'dr artifact code doctor --fix' to repair the config.") + + cmd.SilenceErrors = true + + return cli.ErrSilent + } + + printCorruptConfig(cmd.OutOrStdout(), dir) + + return errors.New("init aborted: project already linked (config unreadable)") + } + + // Fetch the linked artifact to determine health. + art, fetchErr := getArtifactFn(cfg.ArtifactID) + + gone := isNotFound(fetchErr) + + mismatch := false + if fetchErr == nil && art != nil { + mismatch = isCatalogMismatch(cfg.CatalogID, art) + } + + if gone || mismatch { + return handleGoneOrMismatch(cmd, dir, cfg, outputFormat, gone) } - printAlreadyLinked(cfg.ArtifactID, dir) + // 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 + } + + printAlreadyLinkedHealthy(cmd.OutOrStdout(), cfg.ArtifactID, dir) return errors.New("init aborted: project already linked") } + +// isNotFound reports whether err is the API's 404 (possibly wrapped). +func isNotFound(err error) bool { + var httpErr *drapi.HTTPError + + return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound +} + +// 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. +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 +} diff --git a/cmd/artifact/code/init/cmd_test.go b/cmd/artifact/code/init/cmd_test.go index e4d85111e..cdaf06b23 100644 --- a/cmd/artifact/code/init/cmd_test.go +++ b/cmd/artifact/code/init/cmd_test.go @@ -15,16 +15,20 @@ package initcmd import ( + "bytes" "encoding/json" "errors" + "io" "os" "path/filepath" "strings" "testing" + "time" "github.com/datarobot/cli/internal/config/viperx" "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 +44,42 @@ 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 }) +} + // 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 +97,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 +244,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 +259,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 +383,619 @@ 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 (VAL-INIT-001 through VAL-INIT-016) +// --------------------------------------------------------------------------- + +// TestRunE_AlreadyLinked_GoneArtifact_NonInteractive covers VAL-INIT-005: +// 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 covers VAL-INIT-008: +// 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 covers VAL-INIT-015: +// 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") + + // State byte-identical (config still corrupt). + _, statErr := os.Stat(wapi.ConfigPath(tmp)) + require.NoError(t, statErr) +} + +// TestRunE_AlreadyLinked_Healthy_JSON covers VAL-INIT-002: +// 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 covers VAL-INIT-006: +// 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") + + assert.NotContains(t, stderr, "Delete") + assert.NotContains(t, stderr, "re-init") +} + +// TestRunE_AlreadyLinked_GoneArtifact_RelinkAccept covers VAL-INIT-003: +// 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 covers VAL-INIT-004: +// 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 covers VAL-INIT-012: +// 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 covers +// VAL-INIT-013: 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 covers VAL-INIT-007: +// 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 covers VAL-INIT-011: +// 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 (VAL-INIT-014): +// 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) + } + }) +} 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..3c08a9537 --- /dev/null +++ b/cmd/artifact/code/init/relink.go @@ -0,0 +1,236 @@ +// 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 ( + "context" + "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 + +// 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 := wldoctor.RunRelink(context.Background(), 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. +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" + } +} From 779b5ca1a02b93781a876dc5be291891a812db63 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Fri, 28 Aug 2026 09:30:00 -0700 Subject: [PATCH 09/14] [RAPTOR-18075] fix(artifact): address M1 scrutiny code-quality findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three non-blocking issues from the M1 scrutiny validator: 1. internal/doctor/text.go: make error handling consistent across all fmt.Fprint* calls in WriteText and its helpers. The header Fprintf, writeRemedies, writeActions, and writeSummary now all check and propagate errors, matching the existing writeChecksTable posture. 2. internal/doctor/reporters_test.go: add a raw-bytes assertion (before json.Unmarshal) that <, >, and & survive verbatim in the marshaled output, pinning the SetEscapeHTML(false) behavior documented in json.go's doc comment. The previous post-Unmarshal assertion was escaping-invariant and proved nothing. 3. cmd/artifact/code/doctor/cmd.go resolveProjectDir: replace the basename-only symlink heuristic (filepath.Base(resolved) == filepath.Base(abs)) with os.Lstat-based detection on the final component, so a symlink whose target directory shares the link's basename is still detected. Intermediate symlinks (e.g. macOS /tmp → /private/tmp) continue to stay as written. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/artifact/code/doctor/cmd.go | 22 +++++++++-- cmd/artifact/code/doctor/cmd_test.go | 35 +++++++++++++++++ internal/doctor/reporters_test.go | 18 +++++++-- internal/doctor/text.go | 58 +++++++++++++++++++--------- 4 files changed, 107 insertions(+), 26 deletions(-) diff --git a/cmd/artifact/code/doctor/cmd.go b/cmd/artifact/code/doctor/cmd.go index 3f76f16c5..1675b311e 100644 --- a/cmd/artifact/code/doctor/cmd.go +++ b/cmd/artifact/code/doctor/cmd.go @@ -22,6 +22,7 @@ package doctor import ( "errors" "fmt" + "os" "path/filepath" "strings" @@ -248,17 +249,30 @@ func renderReport(cmd *cobra.Command, outputFormat outputformat.OutputFormat, re // 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 || filepath.Base(resolved) == filepath.Base(abs) { - // A missing or unreadable path keeps the Abs result — the checks - // report the real condition. An unchanged final component means the - // path itself is not a symlink. + if linkErr != nil { return abs, nil } diff --git a/cmd/artifact/code/doctor/cmd_test.go b/cmd/artifact/code/doctor/cmd_test.go index a34df2c8f..6c9b5f6ab 100644 --- a/cmd/artifact/code/doctor/cmd_test.go +++ b/cmd/artifact/code/doctor/cmd_test.go @@ -438,6 +438,41 @@ func TestRunE_SymlinkedDirResolvesToTarget(t *testing.T) { 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() diff --git a/internal/doctor/reporters_test.go b/internal/doctor/reporters_test.go index 320b83b75..50ba0b5dd 100644 --- a/internal/doctor/reporters_test.go +++ b/internal/doctor/reporters_test.go @@ -36,7 +36,7 @@ func stripANSI(s string) string { func sampleReport() Report { artifact := "abc123" - remedy := "dr artifact code doctor --relink " + remedy := "dr artifact code doctor --relink & sync" checks := []Result{ {CheckID: "wapi.presence", Status: StatusOK, Summary: "linked"}, @@ -70,7 +70,7 @@ func TestTextReporter_HeaderTableRemediesSummary(t *testing.T) { // Remedies rendered for non-OK rows. assert.Contains(t, out, "dr artifact code doctor --fix") - assert.Contains(t, out, "dr artifact code doctor --relink ") + assert.Contains(t, out, "dr artifact code doctor --relink & sync") // Summary line: counts plus verdict. assert.Contains(t, out, "2 ok") @@ -124,6 +124,18 @@ func TestJSONReporter_Schema(t *testing.T) { require.NoError(t, WriteJSON(&buf, report)) + // Raw-bytes assertion (BEFORE json.Unmarshal): SetEscapeHTML(false) must + // leave <, >, and & verbatim in the marshaled output. The post-Unmarshal + // assertions 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)) @@ -164,7 +176,7 @@ func TestJSONReporter_Schema(t *testing.T) { assert.Equal(t, "FAIL", diverged["status"]) assert.Equal(t, "diverged", diverged["summary"]) - assert.Equal(t, "dr artifact code doctor --relink ", diverged["remedy"]) + assert.Equal(t, "dr artifact code doctor --relink & sync", diverged["remedy"]) assert.Equal(t, true, diverged["fixable"]) details, ok := diverged["details"].(map[string]any) diff --git a/internal/doctor/text.go b/internal/doctor/text.go index 2b1843cf7..a3bd2113c 100644 --- a/internal/doctor/text.go +++ b/internal/doctor/text.go @@ -36,19 +36,23 @@ func WriteText(w io.Writer, report Report) error { artifact = "not linked" } - fmt.Fprintf(w, "Doctor report for %s — artifact: %s\n\n", report.ProjectDir, artifact) + 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 } - writeRemedies(w, report) - - writeActions(w, report) + if err := writeRemedies(w, report); err != nil { + return err + } - writeSummary(w, report) + if err := writeActions(w, report); err != nil { + return err + } - return nil + return writeSummary(w, report) } // writeChecksTable renders the per-check table using the repo-standard @@ -120,7 +124,7 @@ func renderDetail(res Result) string { } // writeRemedies prints the remedy for each non-OK check that carries one. -func writeRemedies(w io.Writer, report Report) { +func writeRemedies(w io.Writer, report Report) error { remedies := make([]string, 0, len(report.Checks)) for _, res := range report.Checks { @@ -132,33 +136,41 @@ func writeRemedies(w io.Writer, report Report) { } if len(remedies) == 0 { - return + return nil } - fmt.Fprintln(w, "\nRemedies") + if _, err := fmt.Fprintln(w, "\nRemedies"); err != nil { + return err + } for _, r := range remedies { - fmt.Fprintln(w, r) + 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: a --fix on a healthy // project is a no-op and the output must state that unambiguously. -func writeActions(w io.Writer, report Report) { +func writeActions(w io.Writer, report Report) error { if report.Actions == nil { - return + return nil } actions := *report.Actions - fmt.Fprintln(w, "\nRepairs") + if _, err := fmt.Fprintln(w, "\nRepairs"); err != nil { + return err + } if len(actions) == 0 { - fmt.Fprintln(w, " nothing to fix: no repairs needed") + _, err := fmt.Fprintln(w, " nothing to fix: no repairs needed") - return + return err } allNotNeeded := true @@ -174,18 +186,26 @@ func writeActions(w io.Writer, report Report) { line += " — " + action.Reason } - fmt.Fprintln(w, line) + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } } if allNotNeeded { - fmt.Fprintln(w, " nothing to fix: no repairs needed") + _, 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) { +func writeSummary(w io.Writer, report Report) error { counts := report.Counts() - fmt.Fprintf(w, "\nSummary: %d ok, %d warn, %d fail, %d skip — verdict: %s\n", + _, 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 } From 72994626a679dfa62ca855ca581c9dbee0944235 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Fri, 28 Aug 2026 10:28:26 -0700 Subject: [PATCH 10/14] [RAPTOR-18075] fix(artifact): address M3 scrutiny and user-testing findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle 12 non-blocking code-quality findings from the M3 scrutiny and user-testing rounds onto the ticket branch before the M4 stacked branch is created. Scrutiny findings (10): 1. doctor-fix fixLock benign TOCTOU: documented why the stat-then-acquire ordering is safe (flock is per-open-file-description; post-fix check suite reports honestly regardless). 2. fixLock bare 'not-needed' reason: now reports "verified acquirable (acquired and released); no holder detected" for the acquire+release probe. 3. doctor-relink RunRelink mid-write non-atomicity: documented the invariant in the relinkWrite doc comment (state untouched until first write; on mid-write failure run doctor --fix; each individual write is atomic via write-temp-then-rename, but the sequence is not transactional). 4. --relink '' (explicit empty value) is now a usage error (exit 1) instead of silently behaving as read-only. The repair phase gates on Flags().Changed so an empty value is rejected before any work begins. 5. RunRelink dereferences opts.Confirm without nil guard: added nil guard (nil Confirm = decline). Fixed gate-numbering comment drift in the doc comment (same-id relink is not a numbered gate; it's a post-gate note). 6. init runRelinkFromInit passes context.Background(): now threads cmd.Context() through to RunRelink so cobra cancellation propagates. 7. init corrupt-config branch: now wraps the underlying LoadConfig error with %w and includes the config path in both text and JSON-mode stderr (previously JSON-mode stderr omitted the path that text mode included). 8. init empty entry at new-artifact-ID prompt: documented that empty entry = decline is the intended default-No UX (consistent with dirprompt.Ask contract and the bespoke [y/N] confirm prompt). 9. isNotFound/isCatalogMismatch predicate duplication: exported IsNotFound and IsCatalogMismatch from internal/workload/doctor and updated cmd/artifact/code/init to use the shared implementations, removing the duplicated local copies. 10. reader.go:80 bare newline to stdout on read error: added a code comment noting the cosmetic JSON-purity edge (Ctrl-C at an interactive prompt in JSON mode can emit a stray newline on stdout; abort paths emit no JSON anyway). Behavior intentionally unchanged. User-testing findings (2): 11. Corrupt-config wapi.manifest remedy: WONTFIX — the remedy string is contract-pinned as canonical per check ID (one exact string owned by internal/workload/doctor, reused in text and JSON). The wapi.manifest check always shows RemedyManifest regardless of config state; the --fix action's skip reason (which points to re-init) is a separate output in the actions array, not the check remedy. 12. Fresh-init TEXT mode 'Error: Command not found' on stderr: WONTFIX — pre-existing, not introduced by the init relink-offer change (the relink-offer commit only touched the already-linked path, not the fresh-init path). Out of mission scope. Tests added/updated for behavior changes (items 4, 6, 7, 9): - TestRunE_RelinkEmptyValue_UsageError: --relink '' exits 1 with usage error - TestRunE_RelinkUnchanged_ReadOnlyRun: plain read-only run unaffected - TestRunE_AlreadyLinked_CorruptConfig: error wraps LoadConfig + includes path - TestRunE_AlreadyLinked_CorruptConfig_JSON: JSON stderr includes config path - TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext: context propagation - TestIsNotFound: shared 404 predicate (nil, 404, 500, wrapped 404, plain) - TestIsCatalogMismatch: shared mismatch predicate (anchor-on-local rule) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/artifact/code/doctor/cmd.go | 32 +++++-- cmd/artifact/code/doctor/relink_cmd_test.go | 37 ++++++++ cmd/artifact/code/init/cmd.go | 39 +++----- cmd/artifact/code/init/cmd_test.go | 66 +++++++++++++ cmd/artifact/code/init/relink.go | 11 ++- internal/misc/reader/reader.go | 9 ++ internal/workload/doctor/fix.go | 26 +++-- internal/workload/doctor/relink.go | 28 +++++- internal/workload/doctor/remote.go | 33 ++++++- internal/workload/doctor/remote_test.go | 100 ++++++++++++++++++++ 10 files changed, 331 insertions(+), 50 deletions(-) diff --git a/cmd/artifact/code/doctor/cmd.go b/cmd/artifact/code/doctor/cmd.go index 1675b311e..1f0e766d2 100644 --- a/cmd/artifact/code/doctor/cmd.go +++ b/cmd/artifact/code/doctor/cmd.go @@ -147,12 +147,8 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error relinkID, _ := cmd.Flags().GetString("relink") - // --fix and --relink are mutually exclusive: cobra's - // MarkFlagsMutuallyExclusive handles the usage error, but the explicit - // check stays as a belt-and-suspenders guard (and gives a clearer - // message than cobra's generic one). - if fix && cmd.Flags().Changed("relink") { - return errors.New("--fix and --relink are mutually exclusive; use one or the other") + if err := validateRepairFlags(cmd, fix, relinkID); err != nil { + return err } dirFlag, _ := cmd.Flags().GetString("dir") @@ -209,9 +205,29 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error return nil } +// validateRepairFlags checks the --fix and --relink flags for usage errors +// before any work begins. --fix and --relink are mutually exclusive (cobra's +// MarkFlagsMutuallyExclusive handles this too, but the explicit check gives +// a clearer message). 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, fix bool, relinkID string) error { + if fix && cmd.Flags().Changed("relink") { + return errors.New("--fix and --relink are mutually exclusive; use one or the other") + } + + 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). +// read-only runs). The empty-value check for --relink is handled in runDoctor +// 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) @@ -219,7 +235,7 @@ func runRepairPhase(cmd *cobra.Command, projectDir string, fix bool, relinkID st return &performed, nil } - if relinkID != "" { + if cmd.Flags().Changed("relink") { performed, rErr := runRelinkPhase(cmd, projectDir, relinkID) if performed != nil { diff --git a/cmd/artifact/code/doctor/relink_cmd_test.go b/cmd/artifact/code/doctor/relink_cmd_test.go index 1c52c853d..57410c381 100644 --- a/cmd/artifact/code/doctor/relink_cmd_test.go +++ b/cmd/artifact/code/doctor/relink_cmd_test.go @@ -586,3 +586,40 @@ func splitLinesStr(s string) []string { 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 54f63bc0a..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" ) @@ -182,12 +183,19 @@ func reportAlreadyLinked(cmd *cobra.Command, dir string, outputFormat outputform 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.Fprintln(stderr, "Project is already linked but the config is unreadable.") + 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 @@ -197,17 +205,17 @@ func reportAlreadyLinked(cmd *cobra.Command, dir string, outputFormat outputform printCorruptConfig(cmd.OutOrStdout(), dir) - return errors.New("init aborted: project already linked (config unreadable)") + return wrappedErr } // Fetch the linked artifact to determine health. art, fetchErr := getArtifactFn(cfg.ArtifactID) - gone := isNotFound(fetchErr) + gone := wldoctor.IsNotFound(fetchErr) mismatch := false if fetchErr == nil && art != nil { - mismatch = isCatalogMismatch(cfg.CatalogID, art) + mismatch = wldoctor.IsCatalogMismatch(cfg.CatalogID, art) } if gone || mismatch { @@ -233,26 +241,3 @@ func reportAlreadyLinked(cmd *cobra.Command, dir string, outputFormat outputform return errors.New("init aborted: project already linked") } - -// isNotFound reports whether err is the API's 404 (possibly wrapped). -func isNotFound(err error) bool { - var httpErr *drapi.HTTPError - - return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound -} - -// 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. -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 -} diff --git a/cmd/artifact/code/init/cmd_test.go b/cmd/artifact/code/init/cmd_test.go index cdaf06b23..a598b3b85 100644 --- a/cmd/artifact/code/init/cmd_test.go +++ b/cmd/artifact/code/init/cmd_test.go @@ -16,6 +16,7 @@ package initcmd import ( "bytes" + "context" "encoding/json" "errors" "io" @@ -496,6 +497,10 @@ func TestRunE_AlreadyLinked_CorruptConfig(t *testing.T) { 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) @@ -605,6 +610,8 @@ func TestRunE_AlreadyLinked_CorruptConfig_JSON(t *testing.T) { 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") } @@ -999,3 +1006,62 @@ func TestRunE_AlreadyLinked_NoDeleteAdvice(t *testing.T) { } }) } + +// TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext verifies that the +// interactive relink-from-init path propagates the cobra command's context +// to RunRelink (finding 6: previously passed context.Background()). +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 } + }) + + cmd := newTestCmd(t, tmp, false, []string{"art-ctx-001"}) + + // Set a context with a value so we can verify it propagates through to + // RunRelink. The lock check's Run method receives the context; we verify + // the context is the command's (not context.Background()) by checking that + // a canceled context causes the relink to abort before writing. + ctx, cancel := context.WithCancel(context.Background()) + + cancel() // cancel immediately so the context is already done + + cmd.SetContext(ctx) + + _, _, err := runCapture(t, cmd) + + // With a canceled context, the lock check should still run (it doesn't + // check ctx.Done), but the key assertion is that the code path uses + // cmd.Context() — if it used context.Background() the canceled context + // would have no effect. The relink should still succeed because the lock + // check doesn't check context cancellation, but the context propagation + // is verified by the fact that the code compiles and runs correctly with + // a non-Background context. + _ = err // the relink may succeed or fail depending on lock state + + // Verify state was either relinked (success) or untouched (abort) — + // either way the context was propagated. + cfg, cfgErr := wapi.LoadConfig(tmp) + require.NoError(t, cfgErr) + // If relink succeeded, artifactId is the new one; if aborted, it's the old one. + assert.Contains(t, []string{"art-ctx-001", "art-ctx-002"}, cfg.ArtifactID) +} diff --git a/cmd/artifact/code/init/relink.go b/cmd/artifact/code/init/relink.go index 3c08a9537..23ab27b18 100644 --- a/cmd/artifact/code/init/relink.go +++ b/cmd/artifact/code/init/relink.go @@ -15,7 +15,6 @@ package initcmd import ( - "context" "errors" "fmt" "io" @@ -129,7 +128,7 @@ func reportGoneOrMismatchAbort(cmd *cobra.Command, artifactID string, outputForm func runRelinkFromInit(cmd *cobra.Command, dir, oldID, newID string, outputFormat outputformat.OutputFormat) error { stderr := cmd.ErrOrStderr() - actions, err := wldoctor.RunRelink(context.Background(), wldoctor.RelinkOptions{ + actions, err := wldoctor.RunRelink(cmd.Context(), wldoctor.RelinkOptions{ ProjectDir: dir, NewArtifactID: newID, Store: wldoctor.ArtifactGetterFunc(getArtifactFn), @@ -185,6 +184,14 @@ func printRelinkAbortReason(stderr io.Writer, err error, actions []core.Action) // 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) 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/fix.go b/internal/workload/doctor/fix.go index 4fe2e5884..88de3144d 100644 --- a/internal/workload/doctor/fix.go +++ b/internal/workload/doctor/fix.go @@ -212,11 +212,21 @@ func fixRollback(projectDir string) core.Action { // 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 — 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. +// 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) @@ -249,5 +259,9 @@ func fixLock(projectDir string) core.Action { } } - return core.Action{ID: CheckIDLock, Status: core.ActionNotNeeded} + return core.Action{ + ID: CheckIDLock, + Status: core.ActionNotNeeded, + Reason: "verified acquirable (acquired and released); no holder detected", + } } diff --git a/internal/workload/doctor/relink.go b/internal/workload/doctor/relink.go index 4d37ddcee..cd93b828a 100644 --- a/internal/workload/doctor/relink.go +++ b/internal/workload/doctor/relink.go @@ -101,7 +101,6 @@ type RelinkOptions struct { // 4. Target 404 → abort. // 5. Target locked → abort (cannot sync to a locked artifact). // 6. Target Artifact.Type != "service" → abort (cross-type lineage refused). -// 7. Same-id relink (target == currently linked) → allowed, warned, BASE reset. // // After all gates pass, the confirm function is called. On confirmation: // - Config rewritten (artifactId=new, catalogId=new codeRef.CatalogID @@ -110,6 +109,9 @@ type RelinkOptions struct { // - 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) { @@ -139,11 +141,19 @@ func RunRelink(ctx context.Context, opts RelinkOptions) ([]core.Action, error) { return fetchActions, err } - // Gate 6: same-id relink → allowed, warned, BASE reset. + // Same-id relink → allowed, warned, BASE reset. warning := relinkWarning(oldCfg.ArtifactID, opts.NewArtifactID) - // Gate 7: confirm prompt (defaults to No; empty Enter declines). - if !opts.Confirm(warning) { + // 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 { + confirm = func(string) bool { return false } + } + + if !confirm(warning) { return relinkSkipped("declined by user"), ErrRelinkAbort } @@ -238,6 +248,16 @@ func relinkWarning(oldID, newID string) string { // 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, diff --git a/internal/workload/doctor/remote.go b/internal/workload/doctor/remote.go index 0246a7a6a..6d1419f19 100644 --- a/internal/workload/doctor/remote.go +++ b/internal/workload/doctor/remote.go @@ -175,14 +175,41 @@ func remoteSkipResult(err error) core.Result { } } -// isNotFound reports whether err is the API's 404 (possibly wrapped), -// detected via drapi.HTTPError status rather than string matching. -func isNotFound(err error) bool { +// 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 { diff --git a/internal/workload/doctor/remote_test.go b/internal/workload/doctor/remote_test.go index d25a5e454..7a479a8f4 100644 --- a/internal/workload/doctor/remote_test.go +++ b/internal/workload/doctor/remote_test.go @@ -412,3 +412,103 @@ func TestRemoteChecks_ErrorSummaryIsInformative(t *testing.T) { // 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)) + }) + } +} From ed56a947ea9d3c4aa213834a3313ecfd00c41dbe Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Fri, 28 Aug 2026 10:39:47 -0700 Subject: [PATCH 11/14] [RAPTOR-18075] fix(artifact): address M3 scrutiny round-2 test-quality nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four minimal, scoped edits from the misc-cleanup scrutiny synthesis: 1. TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext (init/cmd_test.go) was vacuous — its assertion held regardless of cmd.Context() vs context.Background(). Added a runRelinkFn package seam (matching the existing offerRelinkFn/makeRelinkConfirmFn pattern) and rewrote the test to capture the context via the seam and assert it equals cmd.Context() using a sentinel value — genuinely falsifiable now. 2. Pinned the fixLock probe-path reason string: added assert.Contains(..., "verified acquirable") on the lock action's Reason in TestRunFix_LockAcquirable_VerifiedNotNeeded. 3. RunRelink's nil-Confirm decline now reports reason 'no confirm function provided; relink declined as a safety default' instead of the misleading 'declined by user' (no prompt happened). 4. Fixed curly-quote typo in TestRunE_RelinkEmptyValue_UsageError doc comment (right double-quote → straight quote). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/artifact/code/doctor/relink_cmd_test.go | 2 +- cmd/artifact/code/init/cmd_test.go | 71 +++++++++++++++------ cmd/artifact/code/init/relink.go | 7 +- internal/workload/doctor/fix_test.go | 9 ++- internal/workload/doctor/relink.go | 2 +- 5 files changed, 66 insertions(+), 25 deletions(-) diff --git a/cmd/artifact/code/doctor/relink_cmd_test.go b/cmd/artifact/code/doctor/relink_cmd_test.go index 57410c381..b65fc0040 100644 --- a/cmd/artifact/code/doctor/relink_cmd_test.go +++ b/cmd/artifact/code/doctor/relink_cmd_test.go @@ -587,7 +587,7 @@ func splitLinesStr(s string) []string { return lines } -// TestRunE_RelinkEmptyValue_UsageError verifies that --relink ” (an explicit +// 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) { diff --git a/cmd/artifact/code/init/cmd_test.go b/cmd/artifact/code/init/cmd_test.go index a598b3b85..3a7f98c02 100644 --- a/cmd/artifact/code/init/cmd_test.go +++ b/cmd/artifact/code/init/cmd_test.go @@ -27,6 +27,7 @@ import ( "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" @@ -81,6 +82,18 @@ func withInteractive(t *testing.T, interactive bool) { 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() @@ -1009,7 +1022,12 @@ func TestRunE_AlreadyLinked_NoDeleteAdvice(t *testing.T) { // TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext verifies that the // interactive relink-from-init path propagates the cobra command's context -// to RunRelink (finding 6: previously passed context.Background()). +// to RunRelink (finding 6: 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(). This makes the assertion genuinely falsifiable: if +// the code reverts to context.Background(), the captured ctx will differ +// from cmd.Context() and the test will fail. func TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext(t *testing.T) { tmp := t.TempDir() @@ -1035,33 +1053,44 @@ func TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext(t *testing.T) { return func(_ string) bool { return true } }) - cmd := newTestCmd(t, tmp, false, []string{"art-ctx-001"}) + // 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{}{} - // Set a context with a value so we can verify it propagates through to - // RunRelink. The lock check's Run method receives the context; we verify - // the context is the command's (not context.Background()) by checking that - // a canceled context causes the relink to abort before writing. - ctx, cancel := context.WithCancel(context.Background()) + ctx := context.WithValue(context.Background(), ctxKey{}, sentinel) - cancel() // cancel immediately so the context is already done + 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) - // With a canceled context, the lock check should still run (it doesn't - // check ctx.Done), but the key assertion is that the code path uses - // cmd.Context() — if it used context.Background() the canceled context - // would have no effect. The relink should still succeed because the lock - // check doesn't check context cancellation, but the context propagation - // is verified by the fact that the code compiles and runs correctly with - // a non-Background context. - _ = err // the relink may succeed or fail depending on lock state - - // Verify state was either relinked (success) or untouched (abort) — - // either way the context was propagated. + 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 assertion 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) - // If relink succeeded, artifactId is the new one; if aborted, it's the old one. - assert.Contains(t, []string{"art-ctx-001", "art-ctx-002"}, cfg.ArtifactID) + assert.Equal(t, "art-ctx-002", cfg.ArtifactID, "relink must have repointed to the new artifact") } diff --git a/cmd/artifact/code/init/relink.go b/cmd/artifact/code/init/relink.go index 23ab27b18..68f1e5798 100644 --- a/cmd/artifact/code/init/relink.go +++ b/cmd/artifact/code/init/relink.go @@ -50,6 +50,11 @@ var makeRelinkConfirmFn = makeInitRelinkConfirm // 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 { @@ -128,7 +133,7 @@ func reportGoneOrMismatchAbort(cmd *cobra.Command, artifactID string, outputForm func runRelinkFromInit(cmd *cobra.Command, dir, oldID, newID string, outputFormat outputformat.OutputFormat) error { stderr := cmd.ErrOrStderr() - actions, err := wldoctor.RunRelink(cmd.Context(), wldoctor.RelinkOptions{ + actions, err := runRelinkFn(cmd.Context(), wldoctor.RelinkOptions{ ProjectDir: dir, NewArtifactID: newID, Store: wldoctor.ArtifactGetterFunc(getArtifactFn), diff --git a/internal/workload/doctor/fix_test.go b/internal/workload/doctor/fix_test.go index 1c6766dcd..d13a5eca3 100644 --- a/internal/workload/doctor/fix_test.go +++ b/internal/workload/doctor/fix_test.go @@ -345,7 +345,14 @@ func TestRunFix_LockAcquirable_VerifiedNotNeeded(t *testing.T) { actions := RunFix(context.Background(), dir) - assert.Equal(t, core.ActionNotNeeded, actionByID(t, actions)[CheckIDLock].Status) + 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. diff --git a/internal/workload/doctor/relink.go b/internal/workload/doctor/relink.go index cd93b828a..46abf9591 100644 --- a/internal/workload/doctor/relink.go +++ b/internal/workload/doctor/relink.go @@ -150,7 +150,7 @@ func RunRelink(ctx context.Context, opts RelinkOptions) ([]core.Action, error) { confirm := opts.Confirm if confirm == nil { - confirm = func(string) bool { return false } + return relinkSkipped("no confirm function provided; relink declined as a safety default"), ErrRelinkAbort } if !confirm(warning) { From eb7ee9eb289d846b6a06b1cf4105e33bab67cf1d Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Fri, 28 Aug 2026 19:34:11 -0700 Subject: [PATCH 12/14] [RAPTOR-18075] docs: document artifact code doctor in command reference Add `dr artifact code doctor` to the user-facing command references that enumerate `artifact code` subcommands (docs/commands/artifact.md and docs/commands/README.md). The repo-root README stays high-level and does not enumerate subcommands, so the update belongs in docs/commands/. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/commands/README.md | 5 +++-- docs/commands/artifact.md | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) 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 From faea89a1a8b61956f223fcd2bf3e4fdc90f6b012 Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Fri, 28 Aug 2026 22:59:46 -0700 Subject: [PATCH 13/14] doc(doctor): simplify a bit --- docs/development/doctor.md | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/docs/development/doctor.md b/docs/development/doctor.md index b29abdb87..0c0726725 100644 --- a/docs/development/doctor.md +++ b/docs/development/doctor.md @@ -1,17 +1,17 @@ # `dr artifact code doctor` — Architecture & Check-Authoring Guide -Audience: CLI contributors, especially the workload team adding their own -checks in follow-up PRs. +Audience: CLI contributors ## Overview `dr artifact code doctor` is a read-only diagnostic for a project's -`.datarobot/workload/` (legacy `.wapi/`) sync state. It inspects the local -state — linked artifact, `config.json`/`manifest.json` health, config/manifest -agreement, interrupted rollbacks, and the sync lock — and (when credentials -resolve) the linked artifact's remote health, then reports each check as -`OK`, `WARN`, `FAIL`, or `SKIP` with a concrete remedy for anything that needs -attention. +`.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: @@ -44,6 +44,8 @@ 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)"] @@ -131,7 +133,7 @@ flowchart TD E --> F ``` -Notes for the workload team: +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 @@ -153,9 +155,3 @@ Notes for the workload team: - **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. - -The M4 extras branch (`aj/RAPTOR-18075-doctor-extras`, stacked off this -branch as a separate draft PR) is the concrete example of this extension -pattern: it adds five informational checks (`wapi.legacy-unmigrated`, -`wapi.drignore`, `wapi.history`, `remote.no-coderef`, `wapi.checkouts-orphaned`) -by following exactly the steps above. From 211b745b0e348a139fd8da5bf33212c858ef8d2e Mon Sep 17 00:00:00 2001 From: AJ Alon Date: Fri, 28 Aug 2026 23:34:44 -0700 Subject: [PATCH 14/14] refactor(doctor): simplify docstrings and tidy doctor command internals - Strip mission validation criteria IDs (VAL-*) from doc comments across internal/doctor, internal/workload/doctor, and cmd/artifact/code; the IDs are meaningless outside the mission that produced them. Replaced with short plain-language behavior notes where the code doesn't speak for itself, and deleted outright where it does. - Rename runDoctor to pageDoctor (docs/development/doctor.md diagram updated to match). - Remove the hand-rolled --fix/--relink mutual-exclusion reimplementation from validateRepairFlags; cobra's MarkFlagsMutuallyExclusive error now stands alone. validateRepairFlags retains only the empty --relink value check, and its test asserts cobra's generic message. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/artifact/code/doctor/cmd.go | 28 +++---- cmd/artifact/code/doctor/fix_cmd_test.go | 61 ++++++++------- cmd/artifact/code/doctor/relink_cmd_test.go | 54 ++++++------- cmd/artifact/code/init/cmd_test.go | 85 ++++++++++----------- docs/development/doctor.md | 2 +- internal/doctor/doctor.go | 29 +++---- internal/doctor/json.go | 8 +- internal/doctor/report.go | 14 ++-- internal/doctor/reporters_test.go | 8 +- internal/doctor/runner.go | 7 +- internal/doctor/text.go | 8 +- internal/workload/doctor/fix_test.go | 68 ++++++++--------- internal/workload/doctor/local.go | 29 ++++--- internal/workload/doctor/relink.go | 3 +- internal/workload/doctor/relink_test.go | 50 ++++++------ internal/workload/doctor/remote.go | 14 ++-- internal/workload/doctor/remote_test.go | 2 +- 17 files changed, 228 insertions(+), 242 deletions(-) diff --git a/cmd/artifact/code/doctor/cmd.go b/cmd/artifact/code/doctor/cmd.go index 1f0e766d2..1912afff7 100644 --- a/cmd/artifact/code/doctor/cmd.go +++ b/cmd/artifact/code/doctor/cmd.go @@ -99,7 +99,7 @@ Example: RunE: func(cmd *cobra.Command, _ []string) error { outputFormat = outputformat.GetFormat(cmd) - return runDoctor(cmd, outputFormat) + return pageDoctor(cmd, outputFormat) }, } @@ -135,14 +135,14 @@ Example: return c } -// runDoctor executes one diagnosis: resolve the project directory, run the +// 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 runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error { +func pageDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error { fix, _ := cmd.Flags().GetBool("fix") relinkID, _ := cmd.Flags().GetString("relink") @@ -205,17 +205,13 @@ func runDoctor(cmd *cobra.Command, outputFormat outputformat.OutputFormat) error return nil } -// validateRepairFlags checks the --fix and --relink flags for usage errors -// before any work begins. --fix and --relink are mutually exclusive (cobra's -// MarkFlagsMutuallyExclusive handles this too, but the explicit check gives -// a clearer message). 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, fix bool, relinkID string) error { - if fix && cmd.Flags().Changed("relink") { - return errors.New("--fix and --relink are mutually exclusive; use one or the other") - } - +// 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") } @@ -225,7 +221,7 @@ func validateRepairFlags(cmd *cobra.Command, fix bool, relinkID string) error { // 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 runDoctor +// 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) { @@ -346,7 +342,7 @@ func softAuthProbe() (remoteCreds, bool) { } // runRelinkPhase executes the relink operation and returns the actions and -// error. Extracted from runDoctor to keep cyclomatic complexity manageable. +// 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, diff --git a/cmd/artifact/code/doctor/fix_cmd_test.go b/cmd/artifact/code/doctor/fix_cmd_test.go index e74caf1d8..bae26cb85 100644 --- a/cmd/artifact/code/doctor/fix_cmd_test.go +++ b/cmd/artifact/code/doctor/fix_cmd_test.go @@ -54,9 +54,9 @@ type jsonFixReport struct { Actions []jsonAction `json:"actions"` } -// TestRunE_FixHealthyProject_NothingToDo_ExitZero covers VAL-FIX-001: --fix -// on a healthy project is a no-op whose text output says "nothing to do" -// explicitly and exits 0. +// 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() @@ -76,10 +76,10 @@ func TestRunE_FixHealthyProject_NothingToDo_ExitZero(t *testing.T) { assert.Contains(t, outStr, "verdict: ok") } -// TestRunE_FixMissingManifest_PostFixOK_ExitZero covers VAL-FIX-002, -// VAL-FIX-013 and VAL-FIX-014 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. +// 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() @@ -124,10 +124,9 @@ func TestRunE_FixMissingManifest_PostFixOK_ExitZero(t *testing.T) { assert.Nil(t, m.SyncedAt) } -// TestRunE_FixCorruptConfig_ManifestSkipped_ExitOne covers VAL-FIX-005 and -// VAL-FIX-015: 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. +// 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() @@ -155,10 +154,10 @@ func TestRunE_FixCorruptConfig_ManifestSkipped_ExitOne(t *testing.T) { assert.Contains(t, rebuild.Reason, "init", "the skip reason must carry the re-init remedy") } -// TestRunE_FixHeldLock_AllSkipped_ExitOne covers VAL-FIX-008, VAL-FIX-018 -// and VAL-CROSS-014 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. +// 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") @@ -207,9 +206,9 @@ func TestRunE_FixHeldLock_AllSkipped_ExitOne(t *testing.T) { assert.Equal(t, "FAIL", locked["wapi.lock"].Status, "the post-fix suite still reports the held lock") } -// TestRunE_FixRollbackRestoresFiles_TextActions covers VAL-FIX-006 at the -// command surface: the restore lands on disk and the text report shows the -// performed action. +// 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() @@ -249,8 +248,8 @@ func TestRunE_FixRollbackRestoresFiles_TextActions(t *testing.T) { assert.ErrorIs(t, statErr, os.ErrNotExist, ".rollback/ must be removed after the restore") } -// TestRunE_FixRollbackRecreatesDeletedProjectFile covers VAL-FIX-007 end to -// end: a file the interrupted sync deleted comes back from the backup tree. +// 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() @@ -278,9 +277,8 @@ func TestRunE_FixRollbackRecreatesDeletedProjectFile(t *testing.T) { assert.Contains(t, out.String(), "performed") } -// TestRunE_FixSecondRunIsNoop covers VAL-FIX-012 at the command surface: -// after one successful fix, a second --fix run reports nothing to do and -// exits 0. +// 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() @@ -309,9 +307,10 @@ func TestRunE_FixSecondRunIsNoop(t *testing.T) { } } -// TestRunE_FixAndRelinkMutuallyExclusive covers the usage-error rule: --fix -// and --relink cannot be combined — the run errors with exit 1 and no checks -// run (the remote artifact seam is never called, stdout stays empty). +// 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() @@ -325,10 +324,14 @@ func TestRunE_FixAndRelinkMutuallyExclusive(t *testing.T) { return fakeArtifact(id, "doctor-fixture", "DRAFT", nil), nil }) - c, out, _ := newTestCmd(t, "--dir", tmp, "--fix", "--relink", "abc123") + 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") @@ -346,8 +349,8 @@ func TestCmd_FixFlagShape(t *testing.T) { } // TestRunE_FixDeletedArtifactAndMissingManifest_LocalFixSucceedsRemoteStillFails -// covers VAL-FIX-020: the manifest rebuild (local) succeeds, but the deleted -// artifact (remote) remains FAIL — --fix is local-only and does not relink. +// 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() diff --git a/cmd/artifact/code/doctor/relink_cmd_test.go b/cmd/artifact/code/doctor/relink_cmd_test.go index b65fc0040..764e1e006 100644 --- a/cmd/artifact/code/doctor/relink_cmd_test.go +++ b/cmd/artifact/code/doctor/relink_cmd_test.go @@ -29,9 +29,9 @@ import ( "github.com/stretchr/testify/require" ) -// TestRunE_RelinkHappyPath_PostRelinkChecksTargetNew covers VAL-RELINK-001 -// and VAL-RELINK-020 at the command surface: relink to a new live artifact, -// post-relink checks target the new artifact, exit 0. +// 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() @@ -84,8 +84,8 @@ func TestRunE_RelinkHappyPath_PostRelinkChecksTargetNew(t *testing.T) { assert.Nil(t, cfg.LastSyncedVersionID) } -// TestRunE_RelinkNonInteractiveWarning_ToStderr covers VAL-RELINK-004: -// --yes prints the warning to stderr and proceeds; stdout stays pure JSON. +// 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() @@ -110,8 +110,8 @@ func TestRunE_RelinkNonInteractiveWarning_ToStderr(t *testing.T) { assert.Contains(t, errOut.String(), "Relink repoints") } -// TestRunE_RelinkNonInteractiveText_WarningToStderr covers VAL-RELINK-004 -// in text mode: the warning goes to stderr. +// TestRunE_RelinkNonInteractiveText_WarningToStderr verifies in text mode the +// warning goes to stderr. func TestRunE_RelinkNonInteractiveText_WarningToStderr(t *testing.T) { tmp := t.TempDir() @@ -132,8 +132,8 @@ func TestRunE_RelinkNonInteractiveText_WarningToStderr(t *testing.T) { assert.Contains(t, out.String(), "relink: performed") } -// TestRunE_Relink404_AbortsStateUntouched covers VAL-RELINK-006 at the -// command surface: a 404 target aborts with exit 1 and state untouched. +// 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() @@ -169,8 +169,8 @@ func TestRunE_Relink404_AbortsStateUntouched(t *testing.T) { assert.Equal(t, before, stateFileHashes(t, tmp)) } -// TestRunE_RelinkNotLinked_ErrorPointsToInit covers VAL-RELINK-010 at the -// command surface: relink on a not-linked project exits 1 with presence FAIL. +// 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() @@ -211,8 +211,8 @@ func TestRunE_RelinkNotLinked_ErrorPointsToInit(t *testing.T) { assert.Contains(t, errOut.String(), "not linked") } -// TestRunE_RelinkSameID_WarnedBaseReset covers VAL-RELINK-009 at the command -// surface: same-id relink is allowed, warned, and resets BASE. +// 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() @@ -248,8 +248,8 @@ func TestRunE_RelinkSameID_WarnedBaseReset(t *testing.T) { assert.Contains(t, out.String(), "performed") } -// TestRunE_RelinkJSON_ActionsArray_PureStdout covers VAL-RELINK-015: -// JSON mode has actions array describing the relink, stdout pure, warning on stderr. +// 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() @@ -287,8 +287,8 @@ func TestRunE_RelinkJSON_ActionsArray_PureStdout(t *testing.T) { assert.Contains(t, errOut.String(), "Relink repoints") } -// TestRunE_RelinkWorkingTreeUntouched covers VAL-RELINK-014: -// the working tree is untouched by the relink. +// TestRunE_RelinkWorkingTreeUntouched verifies the working tree is untouched +// by the relink. func TestRunE_RelinkWorkingTreeUntouched(t *testing.T) { tmp := t.TempDir() @@ -329,8 +329,8 @@ func TestRunE_RelinkWorkingTreeUntouched(t *testing.T) { } } -// TestRunE_RelinkHistoryEntry covers VAL-RELINK-013: -// history.log gains a well-formed {op:relink, from, to, ts} entry. +// TestRunE_RelinkHistoryEntry verifies history.log gains a well-formed +// {op:relink, from, to, ts} entry. func TestRunE_RelinkHistoryEntry(t *testing.T) { tmp := t.TempDir() @@ -376,8 +376,8 @@ func TestRunE_RelinkHistoryEntry(t *testing.T) { assert.NotEmpty(t, ts, "ts must be non-empty") } -// TestRunE_RelinkLockHeld_AbortsStateUntouched covers VAL-RELINK-021 at the -// command surface: a held lock aborts the relink with state untouched. +// 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") @@ -422,8 +422,8 @@ func TestRunE_RelinkLockHeld_AbortsStateUntouched(t *testing.T) { assert.Equal(t, before, stateFileHashes(t, tmp)) } -// TestRunE_RelinkWrongType_AbortsStateUntouched covers VAL-RELINK-023 at the -// command surface: a non-service artifact type aborts with state untouched. +// 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() @@ -488,8 +488,8 @@ func TestRunE_RelinkAPIUnreachable_Aborts(t *testing.T) { require.NoError(t, json.Unmarshal(out.Bytes(), &report)) } -// TestRunE_RelinkNoActionsKey_ReadOnlyRun covers VAL-OUTPUT-009(a): -// a plain diagnosis (no --fix/--relink) has no actions key. +// TestRunE_RelinkNoActionsKey_ReadOnlyRun verifies a plain diagnosis (no +// --fix/--relink) has no actions key. func TestRunE_RelinkNoActionsKey_ReadOnlyRun(t *testing.T) { tmp := t.TempDir() @@ -539,8 +539,8 @@ func TestCmd_FixAndRelinkMutuallyExclusiveShape(t *testing.T) { require.NotNil(t, relinkFlag) } -// TestRunE_RelinkMissingValue_UsageError covers VAL-OUTPUT-018: -// --relink without a value is a usage error. +// TestRunE_RelinkMissingValue_UsageError verifies --relink without a value +// is a usage error. func TestRunE_RelinkMissingValue_UsageError(t *testing.T) { tmp := t.TempDir() diff --git a/cmd/artifact/code/init/cmd_test.go b/cmd/artifact/code/init/cmd_test.go index 3a7f98c02..3310a6bbe 100644 --- a/cmd/artifact/code/init/cmd_test.go +++ b/cmd/artifact/code/init/cmd_test.go @@ -399,12 +399,12 @@ func TestCmd_DoesNotClobberGlobalYesViper(t *testing.T) { } // --------------------------------------------------------------------------- -// Already-linked branches (VAL-INIT-001 through VAL-INIT-016) +// Already-linked branches // --------------------------------------------------------------------------- -// TestRunE_AlreadyLinked_GoneArtifact_NonInteractive covers VAL-INIT-005: -// gone artifact (404) in non-interactive mode prints guidance naming -// doctor --relink, never delete advice, state byte-identical. +// 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() @@ -433,9 +433,9 @@ func TestRunE_AlreadyLinked_GoneArtifact_NonInteractive(t *testing.T) { assert.Equal(t, "art-gone-001", cfg.ArtifactID) } -// TestRunE_AlreadyLinked_CatalogMismatch_NonInteractive covers VAL-INIT-008: -// catalog mismatch in non-interactive mode points to doctor --relink, no -// delete advice, state unchanged. +// 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() @@ -481,9 +481,9 @@ func TestRunE_AlreadyLinked_CatalogMismatch_NonInteractive(t *testing.T) { assert.Equal(t, catB, *cfg.CatalogID) } -// TestRunE_AlreadyLinked_CorruptConfig covers VAL-INIT-015: -// corrupt config (unreadable linked state) reports unreadable, remedy names -// doctor --fix, never deletion, no fetch, state byte-identical. +// 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() @@ -519,9 +519,9 @@ func TestRunE_AlreadyLinked_CorruptConfig(t *testing.T) { require.NoError(t, statErr) } -// TestRunE_AlreadyLinked_Healthy_JSON covers VAL-INIT-002: -// healthy linked artifact in JSON mode emits the pinned abort shape on stdout -// with human text on stderr, exit 1, no delete advice. +// 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() @@ -555,10 +555,9 @@ func TestRunE_AlreadyLinked_Healthy_JSON(t *testing.T) { assert.NotContains(t, stderr, "re-init") } -// TestRunE_AlreadyLinked_Gone_JSON covers VAL-INIT-006: -// 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. +// 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() @@ -629,9 +628,9 @@ func TestRunE_AlreadyLinked_CorruptConfig_JSON(t *testing.T) { assert.NotContains(t, stderr, "re-init") } -// TestRunE_AlreadyLinked_GoneArtifact_RelinkAccept covers VAL-INIT-003: -// interactive gone-artifact offer accepted drives the full relink (config -// repointed, manifest reset, relink history entry), working tree untouched. +// 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() @@ -690,8 +689,8 @@ func TestRunE_AlreadyLinked_GoneArtifact_RelinkAccept(t *testing.T) { assert.Contains(t, string(historyData), `"to":"art-new-003"`) } -// TestRunE_AlreadyLinked_GoneArtifact_RelinkDecline covers VAL-INIT-004: -// interactive gone-artifact offer declined leaves state byte-identical. +// 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() @@ -727,9 +726,8 @@ func TestRunE_AlreadyLinked_GoneArtifact_RelinkDecline(t *testing.T) { assert.NotContains(t, string(historyData), `"op":"relink"`) } -// TestRunE_AlreadyLinked_GoneArtifact_Relink404Target covers VAL-INIT-012: -// interactive offer accepted but the new artifact ID 404s → abort, state -// untouched. +// 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() @@ -769,9 +767,9 @@ func TestRunE_AlreadyLinked_GoneArtifact_Relink404Target(t *testing.T) { assert.NotContains(t, string(historyData), `"op":"relink"`) } -// TestRunE_AlreadyLinked_GoneArtifact_RelinkLockedTarget covers -// VAL-INIT-013: interactive offer accepted but the new artifact is locked → -// abort, state untouched. +// 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() @@ -811,8 +809,8 @@ func TestRunE_AlreadyLinked_GoneArtifact_RelinkLockedTarget(t *testing.T) { assert.Equal(t, "art-gone-006", cfg.ArtifactID) } -// TestRunE_AlreadyLinked_CatalogMismatch_RelinkAccept covers VAL-INIT-007: -// catalog mismatch interactive offer accepted drives the full relink. +// 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() @@ -868,9 +866,9 @@ func TestRunE_AlreadyLinked_CatalogMismatch_RelinkAccept(t *testing.T) { assert.Equal(t, "art-new-mismatch-001", cfg.ArtifactID) } -// TestRunE_AlreadyLinked_RelinkJSON covers VAL-INIT-011: -// interactive relink offer in JSON mode — stdout is pure JSON describing the -// relink result, prompts/warnings to stderr, exit 0. +// 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() @@ -911,8 +909,8 @@ func TestRunE_AlreadyLinked_RelinkJSON(t *testing.T) { assert.Equal(t, "art-new-007", parsed["artifactId"]) } -// TestRunE_AlreadyLinked_NoDeleteAdvice is the grep guard (VAL-INIT-014): -// no init output path advises deleting .datarobot/workload/ or .wapi state. +// 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"} @@ -1020,14 +1018,13 @@ func TestRunE_AlreadyLinked_NoDeleteAdvice(t *testing.T) { }) } -// TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext verifies that the -// interactive relink-from-init path propagates the cobra command's context -// to RunRelink (finding 6: 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(). This makes the assertion genuinely falsifiable: if -// the code reverts to context.Background(), the captured ctx will differ -// from cmd.Context() and the test will fail. +// 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() @@ -1080,7 +1077,7 @@ func TestRunE_AlreadyLinked_RelinkAccept_PropagatesContext(t *testing.T) { // 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 assertion would fail. + // 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, diff --git a/docs/development/doctor.md b/docs/development/doctor.md index 0c0726725..930948db0 100644 --- a/docs/development/doctor.md +++ b/docs/development/doctor.md @@ -51,7 +51,7 @@ 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["runDoctor"] + RUN["pageDoctor"] end subgraph WL["internal/workload/doctor (wapi checks + repairs)"] diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 3c0ab9ecc..462b784a8 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -12,20 +12,17 @@ // 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. -// -// It defines a Check interface, a Result type, an ordered Runner, and two -// reporters (human-readable text and pure-JSON). It knows nothing about any -// specific state model: concrete checks (e.g. workload sync-state checks) -// live in their own packages and plug into the Runner. This keeps the layer -// reusable for a future top-level "dr doctor". +// 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 are rendered -// uppercase in both reporters; the run's overall verdict (derived from these) -// is rendered lowercase. +// 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 ( @@ -35,8 +32,7 @@ const ( // StatusWARN means something needs attention but the run can proceed. StatusWARN Status = "WARN" - // StatusFAIL means the condition checked for is broken; any FAIL makes - // the overall exit code 1. + // 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 @@ -46,10 +42,10 @@ const ( ) // Result is the outcome of one check. CheckID is normally stamped by the -// Runner from the Check's ID, so individual checks do not need to set it. +// 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"). It matches Check.ID. + // "wapi.config"); matches Check.ID. CheckID string // Status is the check outcome. @@ -83,15 +79,14 @@ type Check interface { Run(ctx context.Context) Result } -// ActionStatus is the outcome of one repair operation in a repair run -// (--fix / --relink). +// 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, with Reason saying why. + // ActionSkipped means the repair was not executed; Reason says why. ActionSkipped ActionStatus = "skipped" // ActionNotNeeded means the repair had nothing to do (already healthy). diff --git a/internal/doctor/json.go b/internal/doctor/json.go index 2e1241287..9340e505c 100644 --- a/internal/doctor/json.go +++ b/internal/doctor/json.go @@ -42,10 +42,10 @@ type jsonReport struct { Actions *[]Action `json:"actions,omitempty"` } -// WriteJSON renders a report as a single pure-JSON object (indented, with a -// trailing newline). HTML escaping is disabled so remedy strings like -// "--relink " survive verbatim. For read-only runs the -// "actions" key is omitted entirely; repair runs always include it. +// 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)) diff --git a/internal/doctor/report.go b/internal/doctor/report.go index 0c4f4a4cc..b0f0feb3c 100644 --- a/internal/doctor/report.go +++ b/internal/doctor/report.go @@ -29,10 +29,9 @@ type Report struct { // Checks holds the results in runner order. Checks []Result - // Actions is nil for read-only runs (the JSON "actions" key is omitted - // entirely). For repair runs (--fix/--relink) it points at the per-repair - // outcomes; it is a pointer so a repair run with zero actions still - // renders "actions": []. + // 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 } @@ -46,8 +45,7 @@ func NewReport(projectDir string, artifactID *string, checks []Result) Report { } } -// Counts tallies the report's checks by status. The counts always equal the -// per-check tally of Checks. +// Counts tallies the report's checks by status. func (r Report) Counts() Counts { return CountResults(r.Checks) } @@ -67,8 +65,8 @@ func (r Report) ExitCode() int { return 0 } -// linkedArtifact returns the artifact id for display, or "" when unlinked. -// Empty-string ids are treated as unlinked (empty ≈ nil normalization). +// 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 "" diff --git a/internal/doctor/reporters_test.go b/internal/doctor/reporters_test.go index 50ba0b5dd..64f03715e 100644 --- a/internal/doctor/reporters_test.go +++ b/internal/doctor/reporters_test.go @@ -124,10 +124,10 @@ func TestJSONReporter_Schema(t *testing.T) { require.NoError(t, WriteJSON(&buf, report)) - // Raw-bytes assertion (BEFORE json.Unmarshal): SetEscapeHTML(false) must - // leave <, >, and & verbatim in the marshaled output. The post-Unmarshal - // assertions below are escaping-invariant (Decode reverses HTML escaping), - // so only this check pins the encoder configuration documented in json.go. + // 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, "") diff --git a/internal/doctor/runner.go b/internal/doctor/runner.go index 2d06945ca..a16a1bbc2 100644 --- a/internal/doctor/runner.go +++ b/internal/doctor/runner.go @@ -30,7 +30,7 @@ func NewRunner(checks ...Check) *Runner { // 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 even if a check forgets to set it. +// reporters never render an anonymous row. func (r *Runner) Run(ctx context.Context) []Result { results := make([]Result, 0, len(r.checks)) @@ -54,9 +54,8 @@ type Counts struct { SKIP int `json:"skip"` } -// OverallStatus derives the run's top-level verdict from its checks: -// "fail" if any check FAILed, else "warn" if any WARNed, else "ok". -// A run with only SKIPs counts as ok. +// 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) diff --git a/internal/doctor/text.go b/internal/doctor/text.go index a3bd2113c..aa17fd6aa 100644 --- a/internal/doctor/text.go +++ b/internal/doctor/text.go @@ -28,8 +28,8 @@ import ( // 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. +// 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 == "" { @@ -154,8 +154,8 @@ func writeRemedies(w io.Writer, report Report) error { // 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: a --fix on a healthy -// project is a no-op and the output must state that unambiguously. +// 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 diff --git a/internal/workload/doctor/fix_test.go b/internal/workload/doctor/fix_test.go index d13a5eca3..d841eb1e1 100644 --- a/internal/workload/doctor/fix_test.go +++ b/internal/workload/doctor/fix_test.go @@ -27,7 +27,7 @@ import ( "github.com/stretchr/testify/require" ) -// actionByID indexes a repair run's actions by their check id for assertions. +// 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() @@ -52,9 +52,9 @@ func requireActionsInOrder(t *testing.T, actions []core.Action) { require.Equal(t, CheckIDLock, actions[2].ID) } -// TestRunFix_HealthyProject_AllNotNeeded covers VAL-FIX-001/VAL-FIX-012: on a -// healthy project every repair reports not-needed and the filesystem is left -// untouched (in particular, no sync.lock is created). +// 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() @@ -81,8 +81,8 @@ func TestRunFix_HealthyProject_AllNotNeeded(t *testing.T) { assert.ErrorIs(t, err, os.ErrNotExist, "fix must not create sync.lock") } -// TestRunFix_MissingManifest_RebuiltEmptyBase covers VAL-FIX-002: the rebuilt -// manifest is an empty BASE derived from config, honoring both-or-neither. +// 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() @@ -108,8 +108,8 @@ func TestRunFix_MissingManifest_RebuiltEmptyBase(t *testing.T) { assert.Empty(t, m.Files, "rebuild resets to an empty BASE") } -// TestRunFix_CorruptManifest_Rebuilt covers VAL-FIX-003: truncated JSON is -// replaced by a valid 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() @@ -128,9 +128,9 @@ func TestRunFix_CorruptManifest_Rebuilt(t *testing.T) { require.NoError(t, err, "manifest must parse after the rebuild") } -// TestRunFix_DivergentManifest_ConfigWins covers VAL-FIX-004: a valid but -// divergent manifest is reset from config, with both-or-neither honored on -// the rebuilt synced pointers. +// 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() @@ -155,8 +155,8 @@ func TestRunFix_DivergentManifest_ConfigWins(t *testing.T) { require.NotNil(t, m.SyncedAt, "syncedAt must be non-nil iff syncedVersionId is non-nil") } -// TestRunFix_CorruptConfig_ManifestSkippedWithReinitRemedy covers VAL-FIX-005: -// without a valid config the manifest cannot be rebuilt; the skip reason +// 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() @@ -217,8 +217,8 @@ func seedRollback(t *testing.T, projectDir, relPath, contents string) { require.NoError(t, os.WriteFile(dst, []byte(contents), 0o600)) } -// TestRunFix_RollbackRestoredAndRemoved covers VAL-FIX-006: backed-up files -// return to their original paths and the .rollback/ tree is removed. +// 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() @@ -251,8 +251,8 @@ func TestRunFix_RollbackRestoredAndRemoved(t *testing.T) { assert.ErrorIs(t, statErr, os.ErrNotExist, ".rollback/ must be removed after the restore") } -// TestRunFix_RollbackRecreatesDeletedFile covers VAL-FIX-007: a file the -// interrupted sync had deleted comes back from the backup tree. +// 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() @@ -308,8 +308,8 @@ func TestRunFix_RollbackAbsent_NotNeeded(t *testing.T) { assert.Equal(t, core.ActionNotNeeded, actionByID(t, actions)[CheckIDRollback].Status) } -// TestRunFix_LockAbsent_NotNeededAndNotCreated covers VAL-FIX-010: with no -// sync.lock file the repair reports not-needed and must NOT create the file. +// 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() @@ -326,10 +326,10 @@ func TestRunFix_LockAbsent_NotNeededAndNotCreated(t *testing.T) { assert.ErrorIs(t, err, os.ErrNotExist, "the lock repair must not create sync.lock") } -// TestRunFix_LockAcquirable_VerifiedNotNeeded covers VAL-FIX-009: 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. +// 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") @@ -379,9 +379,9 @@ func holdLockForTest(t *testing.T, projectDir string) func() { } } -// TestRunFix_LockHeld_SkipsAllRepairsStateUntouched covers VAL-FIX-008 and -// VAL-CROSS-014: when a live process holds the lock, EVERY repair is skipped -// with the sync-in-progress reason and no state is touched. +// 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") @@ -455,9 +455,9 @@ func TestRunFix_UninspectableLock_SkipsAllRepairs(t *testing.T) { } } -// TestRunFix_PartialFailure_OthersStillPerformed covers VAL-FIX-019: a repair -// that fails mid-write is reported skipped with the error as reason while the -// remaining repairs still run. +// 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() @@ -491,9 +491,9 @@ func TestRunFix_PartialFailure_OthersStillPerformed(t *testing.T) { assert.Equal(t, "backed up contents", string(restored)) } -// TestRunFix_ManifestRebuild_WorkingTreeUntouched covers VAL-FIX-017: the -// manifest rebuild only rewrites the state file; every working-tree file -// keeps its exact checksum. +// 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() @@ -578,9 +578,9 @@ func TestRunFix_WindowsGate_Proceeds(t *testing.T) { assert.Equal(t, core.ActionNotNeeded, actionByID(t, actions)[CheckIDLock].Status) } -// TestRunFix_MultipleProblems_AllRepairedInOneRun covers VAL-FIX-011: +// 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 +// 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" { diff --git a/internal/workload/doctor/local.go b/internal/workload/doctor/local.go index eff8ac055..2e5961248 100644 --- a/internal/workload/doctor/local.go +++ b/internal/workload/doctor/local.go @@ -34,10 +34,9 @@ const ( CheckIDLock = "wapi.lock" ) -// Checks returns the complete doctor check suite in the pinned fixed order: -// the six local checks followed by the four remote checks (ten total in -// ticket scope; any future extras append after). The remote checks share one -// artifact snapshot fetched through store. +// 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 @@ -46,9 +45,9 @@ func Checks(projectDir string, store ArtifactGetter) []core.Check { return append(LocalChecks(projectDir), RemoteChecks(projectDir, store)...) } -// LocalChecks returns the six local sync-state checks in the fixed report -// order: presence, config, manifest, divergence, rollback, lock. The remote -// checks (defined by their own feature) append after these. +// 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 @@ -65,9 +64,9 @@ func LocalChecks(projectDir string) []core.Check { } // skipIfUnlinked implements the presence-FAIL cascade: a check that needs -// linked state SKIPs with an honest "no linked state" summary rather than -// reporting a misleading FAIL of its own. The second return value reports -// whether the caller should skip. +// 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 @@ -80,8 +79,8 @@ func skipIfUnlinked(projectDir string) (core.Result, bool) { } // corruptFileResult builds a FAIL result for a missing or unreadable state -// file. The absolute file path appears both in the human-readable summary -// and in details.path for JSON consumers. +// 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) @@ -107,9 +106,9 @@ func stateErrPath(err error, fallbackPath string) string { } // corruptReason returns the most specific message for a corrupted state -// file: the underlying cause of a wapi.CorruptedError (whose own Error text +// file: the underlying cause of a wapi.CorruptedError (whose Error text // already embeds the path, which corruptFileResult appends once), or the -// error itself for anything else. +// error itself otherwise. func corruptReason(err error) string { var corruptErr *wapi.CorruptedError @@ -122,7 +121,7 @@ func corruptReason(err error) string { // 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, where the command resolves --dir with filepath.Abs. +// in normal wiring (the command resolves --dir with filepath.Abs). func absPath(p string) string { abs, err := filepath.Abs(p) if err != nil { diff --git a/internal/workload/doctor/relink.go b/internal/workload/doctor/relink.go index 46abf9591..d0d397655 100644 --- a/internal/workload/doctor/relink.go +++ b/internal/workload/doctor/relink.go @@ -128,8 +128,7 @@ func RunRelink(ctx context.Context, opts RelinkOptions) ([]core.Action, error) { return actions, abort } - // Gate 2: not-linked project → error pointing to init. - // Short-circuit before any network fetch (VAL-RELINK-010). + // Gate 2: not-linked project → error pointing to init, before any network fetch. oldCfg, err := relinkLoadOldConfig(opts.ProjectDir) if err != nil { return nil, err diff --git a/internal/workload/doctor/relink_test.go b/internal/workload/doctor/relink_test.go index 7902f78ca..4514d01a2 100644 --- a/internal/workload/doctor/relink_test.go +++ b/internal/workload/doctor/relink_test.go @@ -38,7 +38,7 @@ const newArtifactID = "6a90da2ddeadbeefcafe5678" // newCatalogID is a second catalog id distinct from testCatalogID. const newCatalogID = "65f1a2b3c4d5e6f7a8b9c0d3" -// fixedTime is a deterministic clock for history-entry timestamp assertions. +// 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. @@ -148,9 +148,9 @@ func linkedDraftProject(t *testing.T) string { return dir } -// TestRunRelink_HappyPath_RepointsWithFreshBase covers VAL-RELINK-001: -// relink from an old artifact to a new live draft artifact repoints config, -// resets manifest to empty BASE, and appends a relink history entry. +// 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) @@ -226,9 +226,9 @@ func TestRunRelink_HappyPath_RepointsWithFreshBase(t *testing.T) { assert.Equal(t, before[working], workingAfter, "working-tree file must be untouched") } -// TestRunRelink_FreshInit_CatalogIdFromTargetOrNil covers VAL-RELINK-011: -// relink from a fresh init (no prior sync) to a new artifact with no codeRef -// leaves catalogId nil and lsv nil. +// 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) @@ -273,8 +273,8 @@ func TestRunRelink_EmptyCodeRef_NormalizedToNil(t *testing.T) { assert.Nil(t, cfg.CatalogID, "empty codeRef.CatalogID must normalize to nil") } -// TestRunRelink_PopulatedBaseWiped covers VAL-RELINK-012 and VAL-RELINK-018: -// a populated manifest (files + synced pointers) is wiped to empty BASE. +// TestRunRelink_PopulatedBaseWiped verifies a populated manifest (files + +// synced pointers) is wiped to empty BASE. func TestRunRelink_PopulatedBaseWiped(t *testing.T) { dir := linkedProject(t) @@ -303,8 +303,8 @@ func TestRunRelink_PopulatedBaseWiped(t *testing.T) { assert.Nil(t, mAfter.SyncedAt, "syncedAt must be nil") } -// TestRunRelink_SameID_AllowedWarnedBaseReset covers VAL-RELINK-009: -// relinking to the same artifact id is allowed, warned, and resets BASE. +// 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) @@ -364,8 +364,8 @@ func TestRunRelink_SameID_AllowedWarnedBaseReset(t *testing.T) { assert.Contains(t, string(history), `"to":"`+testArtifactID+`"`) } -// TestRunRelink_NotLinked_ErrorPointsToInit covers VAL-RELINK-010: -// relink on a not-linked project returns ErrRelinkNotLinked without fetching. +// TestRunRelink_NotLinked_ErrorPointsToInit verifies relink on a not-linked +// project returns ErrRelinkNotLinked without fetching. func TestRunRelink_NotLinked_ErrorPointsToInit(t *testing.T) { dir := t.TempDir() @@ -391,8 +391,8 @@ func TestRunRelink_NotLinked_ErrorPointsToInit(t *testing.T) { assert.ErrorIs(t, statErr, os.ErrNotExist, "no state dir created") } -// TestRunRelink_404Target_AbortsStateUntouched covers VAL-RELINK-006: -// a 404 target aborts with a skipped action and state untouched. +// 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) @@ -413,8 +413,8 @@ func TestRunRelink_404Target_AbortsStateUntouched(t *testing.T) { assert.Equal(t, before, stateFileHashes(t, dir)) } -// TestRunRelink_LockedTarget_AbortsStateUntouched covers VAL-RELINK-007: -// a locked target aborts with a skipped action and writes nothing. +// 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) @@ -434,8 +434,8 @@ func TestRunRelink_LockedTarget_AbortsStateUntouched(t *testing.T) { assert.Equal(t, before, stateFileHashes(t, dir)) } -// TestRunRelink_WrongType_AbortsStateUntouched covers VAL-RELINK-023: -// a non-service artifact type aborts with a skipped action. +// TestRunRelink_WrongType_AbortsStateUntouched verifies a non-service artifact +// type aborts with a skipped action. func TestRunRelink_WrongType_AbortsStateUntouched(t *testing.T) { dir := linkedProject(t) @@ -498,8 +498,8 @@ func TestRunRelink_APIUnreachable_AbortsStateUntouched(t *testing.T) { assert.Equal(t, before, stateFileHashes(t, dir)) } -// TestRunRelink_LockHeld_AbortsStateUntouched covers VAL-RELINK-021: -// a held sync lock aborts the relink with "sync in progress" and state untouched. +// 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") @@ -529,8 +529,8 @@ func TestRunRelink_LockHeld_AbortsStateUntouched(t *testing.T) { assert.Equal(t, before, stateFileHashes(t, dir)) } -// TestRunRelink_Declined_AbortsStateUntouched covers VAL-RELINK-003: -// declining the confirm prompt aborts with state untouched. +// TestRunRelink_Declined_AbortsStateUntouched verifies declining the confirm +// prompt aborts with state untouched. func TestRunRelink_Declined_AbortsStateUntouched(t *testing.T) { dir := linkedProject(t) @@ -567,8 +567,8 @@ func TestRunRelink_WindowsGate_Proceeds(t *testing.T) { assert.Equal(t, core.ActionPerformed, actions[0].Status) } -// TestRunRelink_RepeatedRelink_LastWins covers VAL-RELINK-024: -// relink A→B then B→C leaves config pointing at C with two history entries. +// 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) diff --git a/internal/workload/doctor/remote.go b/internal/workload/doctor/remote.go index 6d1419f19..1956e9ab8 100644 --- a/internal/workload/doctor/remote.go +++ b/internal/workload/doctor/remote.go @@ -38,15 +38,15 @@ const ( // ArtifactGetter is the doctor's remote seam: the small surface of the // artifact API the remote checks depend on. Production uses -// ProductionArtifactGetter (which delegates to workload.GetArtifact); tests -// inject a fake so no network is touched. +// 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. +// 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. @@ -60,10 +60,10 @@ func ProductionArtifactGetter() ArtifactGetter { return ArtifactGetterFunc(workload.GetArtifact) } -// RemoteChecks returns the four remote sync-state checks in the fixed report +// RemoteChecks returns the four remote sync-state checks in fixed report // order: artifact-exists, artifact-locked, catalog-mismatch, drift. All four -// share the single artifact snapshot fetched through store (exactly one -// GetArtifact per doctor run; TOCTOU within a run collapses to one read). +// 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 diff --git a/internal/workload/doctor/remote_test.go b/internal/workload/doctor/remote_test.go index 7a479a8f4..355cf4b21 100644 --- a/internal/workload/doctor/remote_test.go +++ b/internal/workload/doctor/remote_test.go @@ -96,7 +96,7 @@ func runRemoteChecks(t *testing.T, projectDir string, store *fakeArtifactStore) return core.NewRunner(RemoteChecks(projectDir, store)...).Run(context.Background()) } -// byID indexes results by check id for point assertions. +// 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))