Skip to content

Commit 72dec3f

Browse files
committed
Add runtime doctor command
1 parent 7c1a43f commit 72dec3f

8 files changed

Lines changed: 214 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Added `openexit doctor` for local runtime diagnostics covering version metadata, embedded schemas, and optional validator availability.
56
- Added preview support for fixture-based OpenAI/Anthropic to vLLM/LiteLLM assessment.
67
- Added explicit source/target project initialization and validation consistency checks for all assessment paths.
78
- Clarified fixture-only paths as feature-complete local assessment workflows instead of unfinished versioned scaffolds.

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ golangci-lint:
3737
smoke:
3838
tmp=$$(mktemp -d); \
3939
trap 'rm -rf "$$tmp"' EXIT; \
40+
$(BINARY) doctor; \
4041
$(BINARY) demo "$$tmp/builtin-demo" --out "$$tmp/builtin-demo.zip"; \
4142
test -s "$$tmp/builtin-demo.zip"; \
4243
$(BINARY) verify-bundle "$$tmp/builtin-demo.zip"; \

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ make example VERSION=0.1.0-dev
5252
## Commands
5353

5454
- `openexit version`
55+
- `openexit doctor [--json] [--strict]`
5556
- `openexit init <project-dir> [--source <type> --target <type>]`
5657
- `openexit demo <project-dir> [--source <type>] [--out <file>] [--force]`
5758
- `openexit status --project <project-dir> [--json]`
@@ -97,6 +98,7 @@ Fixture workflows run the full local OpenExit workflow with sample or customer-p
9798
Included in the current implementation:
9899

99100
- CLI skeleton and project init/status.
101+
- Runtime doctor for version metadata, embedded schemas, and optional validator availability.
100102
- Built-in demo project generation for release binaries without repository-local fixture files.
101103
- Project readiness status with pipeline counts, validation state, export readiness, and JSON output for automation.
102104
- One-command deterministic workflow runner for collected projects, with optional evidence bundle export.

docs/cli.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ make build VERSION=0.1.0
1111
./bin/openexit version
1212
```
1313

14+
`openexit doctor` checks the local CLI runtime before a project run. It verifies version metadata, embedded schema compilation, and optional validator availability for `promtool` and `kubeconform`. Missing optional validators are warnings by default; pass `--strict` to make warnings fail, or `--json` for automation.
15+
1416
The minimum local demo is:
1517

1618
```bash

docs/release.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The implementation plan names Datadog to Grafana LGTM, Prometheus-compatible ale
99
Release-blocking v0.1 requirements:
1010

1111
- CLI skeleton, project init, built-in demo, workflow runner, readiness status, version command.
12+
- Runtime doctor for version metadata, embedded schemas, and optional validator availability.
1213
- Datadog fixture collector and read-only live Datadog collector.
1314
- Inventory and assessment manifests with typed validation.
1415
- Source-to-target mapping manifest with candidate paths and manual-review entries.
@@ -44,6 +45,7 @@ The AI provider path is complete for local fixture assessment workflows and incl
4445
`init`, `collect fixture`, `assess`, `map`, `generate --all`, `validate`, `export`.
4546
- [ ] GitHub, Okta, Auth0, Cloudflare, Akamai, OpenAI, and Anthropic fixture/live-collector test coverage pass, and supported fixture provider pipelines validate.
4647
- [ ] `openexit version` prints name, version, commit, and date from release build flags.
48+
- [ ] `openexit doctor` reports passing version/schema checks and warns, rather than crashes, when optional validators are absent.
4749
- [ ] `openexit demo <demo>` creates a complete sample project and evidence bundle from built-in fixture data without repository-local `testdata/`.
4850
- [ ] `openexit run --project <demo> --export --out <zip>` completes a collected project through assessment, mapping, generation, validation, status reporting, and bundle export.
4951
- [ ] `openexit status --project <demo>` reports inventory, assessment, mapping, generated artifacts, validation status, export readiness, and matching `--json` output.

internal/app/command.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func NewRootCommand() *cobra.Command {
3838
SilenceErrors: true,
3939
}
4040
root.AddCommand(newVersionCommand())
41+
root.AddCommand(newDoctorCommand())
4142
root.AddCommand(newInitCommand())
4243
root.AddCommand(newDemoCommand())
4344
root.AddCommand(newStatusCommand())

internal/app/doctor.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package app
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os/exec"
9+
"runtime"
10+
"sort"
11+
"strings"
12+
13+
"github.com/RamazanKara/openexit/internal/version"
14+
publicschemas "github.com/RamazanKara/openexit/schemas"
15+
"github.com/santhosh-tekuri/jsonschema/v6"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
type DoctorReport struct {
20+
Status string `json:"status"`
21+
Checks []DoctorCheck `json:"checks"`
22+
}
23+
24+
type DoctorCheck struct {
25+
Name string `json:"name"`
26+
Status string `json:"status"`
27+
Message string `json:"message,omitempty"`
28+
}
29+
30+
func newDoctorCommand() *cobra.Command {
31+
var jsonOutput, strict bool
32+
cmd := &cobra.Command{
33+
Use: "doctor",
34+
Short: "Check local OpenExit runtime readiness",
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
report := runDoctor()
37+
if jsonOutput {
38+
enc := json.NewEncoder(cmd.OutOrStdout())
39+
enc.SetIndent("", " ")
40+
if err := enc.Encode(report); err != nil {
41+
return err
42+
}
43+
} else {
44+
writeDoctorReport(cmd.OutOrStdout(), report)
45+
}
46+
if report.Status == "failed" || (strict && report.Status == "warning") {
47+
return fmt.Errorf("doctor status: %s", report.Status)
48+
}
49+
return nil
50+
},
51+
}
52+
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Write machine-readable doctor report")
53+
cmd.Flags().BoolVar(&strict, "strict", false, "Treat warnings as failures")
54+
return cmd
55+
}
56+
57+
func runDoctor() DoctorReport {
58+
report := DoctorReport{Status: "passed"}
59+
report.Checks = append(report.Checks,
60+
versionMetadataCheck(),
61+
schemaBundleCheck(),
62+
optionalToolCheck("promtool", "Prometheus rule syntax validation"),
63+
optionalToolCheck("kubeconform", "Kubernetes manifest validation"),
64+
)
65+
sort.SliceStable(report.Checks, func(i, j int) bool { return report.Checks[i].Name < report.Checks[j].Name })
66+
for _, check := range report.Checks {
67+
switch check.Status {
68+
case "failed":
69+
report.Status = "failed"
70+
case "warning":
71+
if report.Status == "passed" {
72+
report.Status = "warning"
73+
}
74+
}
75+
}
76+
return report
77+
}
78+
79+
func versionMetadataCheck() DoctorCheck {
80+
var missing []string
81+
for key, value := range map[string]string{
82+
"name": version.Name,
83+
"version": version.Version,
84+
"commit": version.Commit,
85+
"date": version.Date,
86+
} {
87+
if strings.TrimSpace(value) == "" {
88+
missing = append(missing, key)
89+
}
90+
}
91+
if len(missing) > 0 {
92+
sort.Strings(missing)
93+
return DoctorCheck{Name: "version-metadata", Status: "failed", Message: "missing " + strings.Join(missing, ", ")}
94+
}
95+
var unstamped []string
96+
if version.Commit == "unknown" {
97+
unstamped = append(unstamped, "commit")
98+
}
99+
if version.Date == "unknown" {
100+
unstamped = append(unstamped, "date")
101+
}
102+
if len(unstamped) > 0 {
103+
return DoctorCheck{
104+
Name: "version-metadata",
105+
Status: "warning",
106+
Message: fmt.Sprintf("unstamped %s in %s %s %s/%s", strings.Join(unstamped, ", "), version.Name, version.Version, runtime.GOOS, runtime.GOARCH),
107+
}
108+
}
109+
return DoctorCheck{
110+
Name: "version-metadata",
111+
Status: "passed",
112+
Message: fmt.Sprintf("%s %s %s/%s commit=%s date=%s", version.Name, version.Version, runtime.GOOS, runtime.GOARCH, version.Commit, version.Date),
113+
}
114+
}
115+
116+
func schemaBundleCheck() DoctorCheck {
117+
compiler := jsonschema.NewCompiler()
118+
compiler.DefaultDraft(jsonschema.Draft7)
119+
entries, err := publicschemas.FS.ReadDir(".")
120+
if err != nil {
121+
return DoctorCheck{Name: "schema-bundle", Status: "failed", Message: err.Error()}
122+
}
123+
var names []string
124+
for _, entry := range entries {
125+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".schema.json") {
126+
continue
127+
}
128+
data, err := publicschemas.FS.ReadFile(entry.Name())
129+
if err != nil {
130+
return DoctorCheck{Name: "schema-bundle", Status: "failed", Message: err.Error()}
131+
}
132+
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(data))
133+
if err != nil {
134+
return DoctorCheck{Name: "schema-bundle", Status: "failed", Message: fmt.Sprintf("%s: %v", entry.Name(), err)}
135+
}
136+
if err := compiler.AddResource(entry.Name(), document); err != nil {
137+
return DoctorCheck{Name: "schema-bundle", Status: "failed", Message: fmt.Sprintf("%s: %v", entry.Name(), err)}
138+
}
139+
names = append(names, entry.Name())
140+
}
141+
for _, name := range names {
142+
if _, err := compiler.Compile(name); err != nil {
143+
return DoctorCheck{Name: "schema-bundle", Status: "failed", Message: fmt.Sprintf("%s: %v", name, err)}
144+
}
145+
}
146+
return DoctorCheck{Name: "schema-bundle", Status: "passed", Message: fmt.Sprintf("%d embedded schema(s) compiled", len(names))}
147+
}
148+
149+
func optionalToolCheck(name, purpose string) DoctorCheck {
150+
path, err := exec.LookPath(name)
151+
if err != nil {
152+
return DoctorCheck{Name: name, Status: "warning", Message: purpose + " unavailable; install " + name + " for stronger validation"}
153+
}
154+
return DoctorCheck{Name: name, Status: "passed", Message: path}
155+
}
156+
157+
func writeDoctorReport(w io.Writer, report DoctorReport) {
158+
_, _ = fmt.Fprintf(w, "status: %s\n", report.Status)
159+
for _, check := range report.Checks {
160+
if check.Message == "" {
161+
_, _ = fmt.Fprintf(w, "%s: %s\n", check.Name, check.Status)
162+
continue
163+
}
164+
_, _ = fmt.Fprintf(w, "%s: %s - %s\n", check.Name, check.Status, check.Message)
165+
}
166+
}

internal/app/e2e_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,45 @@ func TestVerifyBundleCommandRejectsTampering(t *testing.T) {
121121
}
122122
}
123123

124+
func TestDoctorCommandReportsRuntimeReadiness(t *testing.T) {
125+
out, err := executeForTestWithOutput("doctor")
126+
if err != nil {
127+
t.Fatalf("openexit doctor failed: %v\n%s", err, out)
128+
}
129+
for _, marker := range []string{
130+
"status:",
131+
"version-metadata:",
132+
"schema-bundle: passed",
133+
"promtool:",
134+
"kubeconform:",
135+
} {
136+
if !strings.Contains(out, marker) {
137+
t.Fatalf("expected doctor marker %q, got:\n%s", marker, out)
138+
}
139+
}
140+
141+
jsonOut, err := executeForTestWithOutput("doctor", "--json")
142+
if err != nil {
143+
t.Fatalf("openexit doctor --json failed: %v\n%s", err, jsonOut)
144+
}
145+
var report DoctorReport
146+
if err := json.Unmarshal([]byte(jsonOut), &report); err != nil {
147+
t.Fatalf("decode doctor JSON: %v\n%s", err, jsonOut)
148+
}
149+
if report.Status == "" || len(report.Checks) < 4 {
150+
t.Fatalf("unexpected doctor report: %+v", report)
151+
}
152+
seen := map[string]bool{}
153+
for _, check := range report.Checks {
154+
seen[check.Name] = true
155+
}
156+
for _, name := range []string{"version-metadata", "schema-bundle", "promtool", "kubeconform"} {
157+
if !seen[name] {
158+
t.Fatalf("doctor report missing check %s: %+v", name, report)
159+
}
160+
}
161+
}
162+
124163
func TestStatusReportsPipelineReadiness(t *testing.T) {
125164
projectDir := filepath.Join(t.TempDir(), "demo")
126165
fixturePath := filepath.Join("..", "..", "testdata", "datadog", "small.json")

0 commit comments

Comments
 (0)