2026-04-13 18:53:36 +00:00
|
|
|
package cli
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-05-05 10:23:14 +00:00
|
|
|
"forge.lclr.dev/AI/mcp-framework/config"
|
|
|
|
|
"forge.lclr.dev/AI/mcp-framework/manifest"
|
|
|
|
|
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
2026-04-13 18:53:36 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-04-20 08:56:15 +00:00
|
|
|
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
|
2026-04-13 18:53:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 07:39:05 +00:00
|
|
|
type BitwardenDoctorOptions struct {
|
|
|
|
|
Command string
|
2026-04-20 09:36:07 +00:00
|
|
|
Debug bool
|
2026-04-20 08:56:15 +00:00
|
|
|
Shell string
|
2026-04-20 07:39:05 +00:00
|
|
|
LookupEnv func(string) (string, bool)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var checkBitwardenReady = secretstore.EnsureBitwardenReady
|
|
|
|
|
|
2026-04-13 18:53:36 +00:00
|
|
|
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))
|
|
|
|
|
}
|
2026-04-16 14:56:00 +00:00
|
|
|
if options.ManifestCheck != nil {
|
|
|
|
|
checks = append(checks, options.ManifestCheck)
|
2026-05-13 08:45:51 +00:00
|
|
|
} else if strings.TrimSpace(options.ManifestDir) != "" {
|
2026-04-16 14:56:00 +00:00
|
|
|
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
|
|
|
|
}
|
2026-04-13 18:53:36 +00:00
|
|
|
if options.ConnectivityCheck != nil {
|
|
|
|
|
checks = append(checks, options.ConnectivityCheck)
|
|
|
|
|
}
|
2026-04-20 08:56:15 +00:00
|
|
|
if shouldAutoIncludeBitwardenCheck(options) {
|
|
|
|
|
checks = append(checks, BitwardenReadyCheck(options.BitwardenOptions))
|
|
|
|
|
}
|
2026-04-13 18:53:36 +00:00
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 07:39:05 +00:00
|
|
|
func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck {
|
|
|
|
|
return func(context.Context) DoctorResult {
|
|
|
|
|
err := checkBitwardenReady(secretstore.Options{
|
|
|
|
|
BitwardenCommand: strings.TrimSpace(options.Command),
|
2026-04-20 09:36:07 +00:00
|
|
|
BitwardenDebug: options.Debug,
|
2026-04-20 08:56:15 +00:00
|
|
|
Shell: strings.TrimSpace(options.Shell),
|
2026-04-20 07:39:05 +00:00
|
|
|
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(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 08:56:15 +00:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:53:36 +00:00
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 14:53:26 +00:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:53:36 +00:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-14 14:53:26 +00:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
}
|