mcp-framework/cli/doctor.go

546 lines
13 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"forge.lclr.dev/AI/mcp-framework/config"
"forge.lclr.dev/AI/mcp-framework/manifest"
"forge.lclr.dev/AI/mcp-framework/secretstore"
)
type DoctorStatus string
const (
DoctorStatusOK DoctorStatus = "ok"
DoctorStatusWarn DoctorStatus = "warn"
DoctorStatusFail DoctorStatus = "fail"
)
type DoctorResult struct {
Name string
Status DoctorStatus
Summary string
Detail string
}
type DoctorCheck func(context.Context) DoctorResult
type DoctorReport struct {
Results []DoctorResult
}
type DoctorSummary struct {
OK int
Warn int
Fail int
Total int
}
type DoctorSecret struct {
Name string
Label string
}
type DoctorManifestValidator func(manifest.File, string) []string
type DoctorOptions struct {
ConfigCheck DoctorCheck
SecretStoreCheck DoctorCheck
SecretBackendPolicy secretstore.BackendPolicy
RequiredSecrets []DoctorSecret
SecretStoreFactory func() (secretstore.Store, error)
ManifestDir string
ManifestValidator DoctorManifestValidator
ManifestCheck DoctorCheck
ConnectivityCheck DoctorCheck
BitwardenOptions BitwardenDoctorOptions
DisableAutoBitwardenCheck bool
ExtraChecks []DoctorCheck
}
type BitwardenDoctorOptions struct {
Command string
Debug bool
Shell string
LookupEnv func(string) (string, bool)
}
var checkBitwardenReady = secretstore.EnsureBitwardenReady
func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
checks := make([]DoctorCheck, 0, 5+len(options.ExtraChecks))
if options.ConfigCheck != nil {
checks = append(checks, options.ConfigCheck)
}
if options.SecretStoreCheck != nil {
checks = append(checks, options.SecretStoreCheck)
}
if len(options.RequiredSecrets) > 0 && options.SecretStoreFactory != nil {
checks = append(checks, RequiredSecretsCheck(options.SecretStoreFactory, options.RequiredSecrets))
}
if options.ManifestCheck != nil {
checks = append(checks, options.ManifestCheck)
} else {
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
}
if options.ConnectivityCheck != nil {
checks = append(checks, options.ConnectivityCheck)
}
if shouldAutoIncludeBitwardenCheck(options) {
checks = append(checks, BitwardenReadyCheck(options.BitwardenOptions))
}
checks = append(checks, options.ExtraChecks...)
results := make([]DoctorResult, 0, len(checks))
for _, check := range checks {
if check == nil {
continue
}
results = append(results, normalizeDoctorResult(check(ctx)))
}
return DoctorReport{Results: results}
}
func (r DoctorReport) Summary() DoctorSummary {
summary := DoctorSummary{Total: len(r.Results)}
for _, result := range r.Results {
switch result.Status {
case DoctorStatusOK:
summary.OK++
case DoctorStatusWarn:
summary.Warn++
case DoctorStatusFail:
summary.Fail++
default:
summary.Fail++
}
}
return summary
}
func (r DoctorReport) HasFailures() bool {
return r.Summary().Fail > 0
}
func RenderDoctorReport(w io.Writer, report DoctorReport) error {
for _, result := range report.Results {
if _, err := fmt.Fprintf(w, "[%s] %s: %s\n", doctorLabel(result.Status), result.Name, result.Summary); err != nil {
return err
}
if detail := strings.TrimSpace(result.Detail); detail != "" {
if _, err := fmt.Fprintf(w, " %s\n", detail); err != nil {
return err
}
}
}
summary := report.Summary()
_, err := fmt.Fprintf(
w,
"Summary: %d ok, %d warning(s), %d failure(s), %d total\n",
summary.OK,
summary.Warn,
summary.Fail,
summary.Total,
)
return err
}
func NewConfigCheck[T any](store config.Store[T]) DoctorCheck {
return func(context.Context) DoctorResult {
path, err := store.ConfigPath()
if err != nil {
return DoctorResult{
Name: "config",
Status: DoctorStatusFail,
Summary: "cannot resolve config path",
Detail: err.Error(),
}
}
info, err := os.Stat(path)
switch {
case err == nil && info.IsDir():
return DoctorResult{
Name: "config",
Status: DoctorStatusFail,
Summary: "config path is a directory",
Detail: path,
}
case err == nil:
if _, err := store.Load(path); err != nil {
return DoctorResult{
Name: "config",
Status: DoctorStatusFail,
Summary: "config file is unreadable or invalid",
Detail: err.Error(),
}
}
return DoctorResult{
Name: "config",
Status: DoctorStatusOK,
Summary: "config file is readable",
Detail: path,
}
case errors.Is(err, os.ErrNotExist):
return DoctorResult{
Name: "config",
Status: DoctorStatusWarn,
Summary: "config file is missing",
Detail: path,
}
default:
return DoctorResult{
Name: "config",
Status: DoctorStatusFail,
Summary: "cannot access config file",
Detail: err.Error(),
}
}
}
}
func SecretStoreAvailabilityCheck(factory func() (secretstore.Store, error)) DoctorCheck {
return func(context.Context) DoctorResult {
if factory == nil {
return DoctorResult{
Name: "secret-store",
Status: DoctorStatusWarn,
Summary: "secret store check is not configured",
}
}
_, err := factory()
if err != nil {
return DoctorResult{
Name: "secret-store",
Status: DoctorStatusFail,
Summary: "secret backend is unavailable",
Detail: err.Error(),
}
}
return DoctorResult{
Name: "secret-store",
Status: DoctorStatusOK,
Summary: "secret backend is available",
}
}
}
func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck {
return func(context.Context) DoctorResult {
err := checkBitwardenReady(secretstore.Options{
BitwardenCommand: strings.TrimSpace(options.Command),
BitwardenDebug: options.Debug,
Shell: strings.TrimSpace(options.Shell),
LookupEnv: options.LookupEnv,
})
if err == nil {
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusOK,
Summary: "bitwarden CLI is ready",
}
}
switch {
case errors.Is(err, secretstore.ErrBWNotLoggedIn):
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusFail,
Summary: "bitwarden login is required",
Detail: err.Error(),
}
case errors.Is(err, secretstore.ErrBWLocked):
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusFail,
Summary: "bitwarden vault is locked or BW_SESSION is missing",
Detail: err.Error(),
}
case errors.Is(err, secretstore.ErrBWUnavailable):
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusFail,
Summary: "bitwarden CLI is unavailable",
Detail: err.Error(),
}
default:
return DoctorResult{
Name: "bitwarden",
Status: DoctorStatusFail,
Summary: "bitwarden readiness check failed",
Detail: err.Error(),
}
}
}
}
func shouldAutoIncludeBitwardenCheck(options DoctorOptions) bool {
if options.DisableAutoBitwardenCheck {
return false
}
if options.SecretBackendPolicy == secretstore.BackendBitwardenCLI {
return true
}
if options.SecretStoreFactory == nil {
return false
}
store, err := options.SecretStoreFactory()
if err == nil {
return secretstore.EffectiveBackendPolicy(store) == secretstore.BackendBitwardenCLI
}
if errors.Is(err, secretstore.ErrBWNotLoggedIn) ||
errors.Is(err, secretstore.ErrBWLocked) ||
errors.Is(err, secretstore.ErrBWUnavailable) {
return true
}
return strings.Contains(strings.ToLower(strings.TrimSpace(err.Error())), "bitwarden")
}
func RequiredSecretsCheck(factory func() (secretstore.Store, error), required []DoctorSecret) DoctorCheck {
return func(context.Context) DoctorResult {
store, err := factory()
if err != nil {
return DoctorResult{
Name: "required-secrets",
Status: DoctorStatusFail,
Summary: "cannot inspect required secrets",
Detail: err.Error(),
}
}
var missing []string
for _, secret := range required {
name := strings.TrimSpace(secret.Name)
if name == "" {
continue
}
_, err := store.GetSecret(name)
switch {
case err == nil:
case errors.Is(err, secretstore.ErrNotFound):
if label := strings.TrimSpace(secret.Label); label != "" {
missing = append(missing, fmt.Sprintf("%s (%s)", name, label))
} else {
missing = append(missing, name)
}
default:
return DoctorResult{
Name: "required-secrets",
Status: DoctorStatusFail,
Summary: fmt.Sprintf("cannot read secret %q", name),
Detail: err.Error(),
}
}
}
if len(missing) > 0 {
return DoctorResult{
Name: "required-secrets",
Status: DoctorStatusFail,
Summary: "required secrets are missing",
Detail: strings.Join(missing, ", "),
}
}
return DoctorResult{
Name: "required-secrets",
Status: DoctorStatusOK,
Summary: "all required secrets are present",
}
}
}
func RequiredResolvedFieldsCheck(options ResolveOptions) DoctorCheck {
required := requiredFieldNames(options.Fields)
return func(context.Context) DoctorResult {
if len(required) == 0 {
return DoctorResult{
Name: "required-profile-fields",
Status: DoctorStatusWarn,
Summary: "no required profile field is configured",
}
}
resolution, err := ResolveFields(options)
if err != nil {
return DoctorResult{
Name: "required-profile-fields",
Status: DoctorStatusFail,
Summary: resolveFieldsErrorSummary(err),
Detail: FormatResolveFieldsError(err),
}
}
sources := make([]string, 0, len(required))
for _, name := range required {
field, ok := resolution.Get(name)
if !ok || !field.Found {
return DoctorResult{
Name: "required-profile-fields",
Status: DoctorStatusFail,
Summary: "required profile values are missing",
Detail: name,
}
}
sources = append(sources, fmt.Sprintf("%s=%s", name, field.Source))
}
return DoctorResult{
Name: "required-profile-fields",
Status: DoctorStatusOK,
Summary: "required profile values are resolved",
Detail: strings.Join(sources, ", "),
}
}
}
func FormatResolveFieldsError(err error) string {
if err == nil {
return ""
}
var missing *MissingRequiredValuesError
if errors.As(err, &missing) {
if len(missing.Fields) == 0 {
return "missing required configuration values"
}
return strings.Join(missing.Fields, ", ")
}
var lookupErr *SourceLookupError
if errors.As(err, &lookupErr) {
key := strings.TrimSpace(lookupErr.Key)
if key == "" {
key = lookupErr.Field
}
return fmt.Sprintf(
"field %q via source %q (key %q): %v",
lookupErr.Field,
lookupErr.Source,
key,
lookupErr.Err,
)
}
return err.Error()
}
func ManifestCheck(startDir string, validator DoctorManifestValidator) DoctorCheck {
return func(context.Context) DoctorResult {
file, path, err := manifest.LoadDefault(startDir)
if err != nil {
return DoctorResult{
Name: "manifest",
Status: DoctorStatusFail,
Summary: "manifest is missing or invalid",
Detail: err.Error(),
}
}
if validator != nil {
issues := validator(file, path)
if len(issues) > 0 {
return DoctorResult{
Name: "manifest",
Status: DoctorStatusFail,
Summary: "manifest validation failed",
Detail: strings.Join(issues, "; "),
}
}
}
return DoctorResult{
Name: "manifest",
Status: DoctorStatusOK,
Summary: "manifest is valid",
Detail: path,
}
}
}
func normalizeDoctorResult(result DoctorResult) DoctorResult {
result.Name = strings.TrimSpace(result.Name)
if result.Name == "" {
result.Name = "unnamed-check"
}
result.Summary = strings.TrimSpace(result.Summary)
if result.Summary == "" {
result.Summary = "no details provided"
}
result.Detail = strings.TrimSpace(result.Detail)
switch result.Status {
case DoctorStatusOK, DoctorStatusWarn, DoctorStatusFail:
default:
result.Status = DoctorStatusFail
}
return result
}
func doctorLabel(status DoctorStatus) string {
switch status {
case DoctorStatusOK:
return "OK"
case DoctorStatusWarn:
return "WARN"
default:
return "FAIL"
}
}
func requiredFieldNames(specs []FieldSpec) []string {
required := make([]string, 0, len(specs))
seen := make(map[string]struct{}, len(specs))
for _, spec := range specs {
if !spec.Required {
continue
}
name := strings.TrimSpace(spec.Name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
required = append(required, name)
}
return required
}
func resolveFieldsErrorSummary(err error) string {
var missing *MissingRequiredValuesError
if errors.As(err, &missing) {
return "required profile values are missing"
}
var lookupErr *SourceLookupError
if errors.As(err, &lookupErr) {
return fmt.Sprintf("cannot resolve profile value %q", lookupErr.Field)
}
return "profile resolution failed"
}