337 lines
7.6 KiB
Go
337 lines
7.6 KiB
Go
|
|
package cli
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"gitea.lclr.dev/AI/mcp-framework/config"
|
||
|
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||
|
|
"gitea.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
|
||
|
|
RequiredSecrets []DoctorSecret
|
||
|
|
SecretStoreFactory func() (secretstore.Store, error)
|
||
|
|
ManifestDir string
|
||
|
|
ManifestValidator DoctorManifestValidator
|
||
|
|
ConnectivityCheck DoctorCheck
|
||
|
|
ExtraChecks []DoctorCheck
|
||
|
|
}
|
||
|
|
|
||
|
|
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))
|
||
|
|
}
|
||
|
|
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
||
|
|
if options.ConnectivityCheck != nil {
|
||
|
|
checks = append(checks, options.ConnectivityCheck)
|
||
|
|
}
|
||
|
|
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 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 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"
|
||
|
|
}
|
||
|
|
}
|