diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 0e6eadbe..64bcd753 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -26,9 +26,9 @@ | `lerd about` | Show version, build info, and project URL | | `lerd man [page]` | Browse the built-in documentation in the terminal; pass a page name to jump directly (e.g. `lerd man sites`) | | `lerd tui` | Open a btop-style terminal dashboard with live site / service / worker status, per-site detail pane, inline domain and version editing, shell drop-in, log tailing, filter + sort, and global settings | -| `lerd check` | Validate `.lerd.yaml` syntax, services, and PHP version before setup | -| `lerd doctor` | Full environment diagnostic: podman, systemd, DNS, ports, PHP images, config validity; also reports how much podman disk is reclaimable. Add `--fix` to apply the safe automatic repairs (confirming each; `--yes` to skip prompts, `--dry-run` to preview); privileged and external-state findings are left for you to run. `--json` emits the findings, each tagged with a fix tier, for tooling | -| `lerd site:doctor [domain]` | App-level health checks for a single site (env file, services the site declares that this machine has never installed, which `--fix` and the dashboard's *Install the missing services* button install, ones that are installed but stopped, which the *Start the stopped services* button starts, services picked in `.lerd.yaml` that the site's env file does not point at, a key the env file sets more than once, env drift, application key, a configured database that is missing (a SQLite file that is absent or empty, or a MySQL/Postgres schema that does not exist on the service), composer/node dependency install + lock, `composer audit`/`npm audit`, PHP version range, an nginx vhost that no longer matches what lerd would write for the site, routes running well above the site's typical response time, plus the framework's own checks). A broken database suppresses the framework migration check so the remedy isn't repeated. Defaults to the site in the current directory; pass a domain to target another. Add `--json` for machine-readable output, or `--fix` to apply the findings lerd can resolve on its own and re-check | +| `lerd check` | Deprecated alias for `lerd site:doctor`, which validates `.lerd.yaml` as one check inside the site's health report | +| `lerd doctor` | Full environment diagnostic: podman, systemd, DNS, ports, PHP images, config validity; also reports how much podman disk is reclaimable, and finishes by sweeping every linked site through the cheap half of `site:doctor`, one summary line each with `lerd site:doctor ` for the detail (the audits and the timing lookup stay on the per-site command). Add `--fix` to apply the safe automatic repairs (confirming each; `--yes` to skip prompts, `--dry-run` to preview) and to offer the disk reclaim it reported; privileged and external-state findings are left for you to run. `--json` emits the findings, each tagged with a fix tier, for tooling | +| `lerd site:doctor [domain]` | App-level health checks for a single site (`.lerd.yaml` validity: PHP version, workers, services, container block, commands, database service, env file, services the site declares that this machine has never installed, which `--fix` and the dashboard's *Install the missing services* button install, ones that are installed but stopped, which the *Start the stopped services* button starts, services picked in `.lerd.yaml` that the site's env file does not point at, a key the env file sets more than once, env drift, application key, a configured database that is missing (a SQLite file that is absent or empty, or a MySQL/Postgres schema that does not exist on the service), composer/node dependency install + lock, `composer audit`/`npm audit`, PHP version range, an nginx vhost that no longer matches what lerd would write for the site, routes running well above the site's typical response time, plus the framework's own checks). A broken database suppresses the framework migration check so the remedy isn't repeated. Defaults to the site in the current directory; pass a domain to target another. Add `--json` for machine-readable output, or `--fix` to apply the findings lerd can resolve on its own and re-check | | `lerd cleanup` | Reclaim podman disk from orphaned lerd images (old PHP build and base images a rebuild left behind), unused service images no installed service references any more (e.g. an old `mysql:8.0` after upgrading, keeping each service's current image and its one-back rollback target), and dangling untagged images. Previews the list and confirms before removing. Never touches a tagged image in use, your databases, or volumes | | `lerd cleanup --dry-run` | Show what would be reclaimed and the approximate size, remove nothing | | `lerd cleanup --safe` | Only reclaim images provably built by lerd, leave unused service and dangling images alone | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 33e57c44..d339c7ff 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3,12 +3,20 @@ When something isn't working, start with the built-in diagnostics: ```bash -lerd doctor # full check: podman, systemd, DNS, ports, images, config +lerd doctor # full check: podman, systemd, DNS, ports, images, config, sites lerd status # quick health snapshot of all running services ``` `lerd doctor` reports OK/FAIL/WARN for each check with a hint for every failure. +It finishes with a `[Sites]` section, one line per linked site, so the broad command is actually broad: an environment that passes everything else while three sites are down is not a healthy machine. Each site runs the cheap half of [`lerd site:doctor`](reference/commands.md), the file-and-config checks, and the line names the command to run for the detail: + +```bash +lerd site:doctor acme.test +``` + +The expensive checks, `composer audit`, `npm audit`, and the response-time lookup, only run there, so the sweep stays quick however many sites you have. `lerd site:doctor` also validates the project's `.lerd.yaml`, which is what `lerd check` used to do on its own; `check` still works as a deprecated alias for it. + ## Repairing findings automatically `lerd doctor --fix` runs the same diagnostic and then offers to repair the findings it safely can. It confirms each fix before applying it, so you can pick and choose: diff --git a/docs/usage/custom-containers.md b/docs/usage/custom-containers.md index 5d1cc95b..526f89a0 100644 --- a/docs/usage/custom-containers.md +++ b/docs/usage/custom-containers.md @@ -92,7 +92,7 @@ container: ssl: true ``` -Nginx will use `proxy_pass https://` and skip certificate verification (`proxy_ssl_verify off`) since the container cert is self-signed. Run `lerd check` to confirm the setting is recognised. +Nginx will use `proxy_pass https://` and skip certificate verification (`proxy_ssl_verify off`) since the container cert is self-signed. Run `lerd site:doctor` to confirm the setting is recognised. ## Hot reload diff --git a/docs/usage/framework-commands.md b/docs/usage/framework-commands.md index ad0a41d4..14b96bf1 100644 --- a/docs/usage/framework-commands.md +++ b/docs/usage/framework-commands.md @@ -63,7 +63,7 @@ The merge rules are: - A project entry with a new `name` is **appended** after the framework set. - Framework entries whose `check:` rule fails are dropped before the merge. -Validation runs as part of `lerd check`. Invalid `output:` values, unknown icons, duplicate names, and missing commands all surface there. +Validation runs as part of `lerd site:doctor`. Invalid `output:` values, unknown icons, duplicate names, and missing commands all surface there. Because a `.lerd.yaml` `commands:` entry comes from the project (an untrusted cloned repo), lerd asks before running one on your host: the first run via `lerd run` or the dashboard shows the exact command and prompts, and the approval is remembered per site so later runs don't re-prompt. `lerd run --yes` bypasses the prompt, and `host_commands.skip_confirmation: true` (or `host_commands.disabled: true` to refuse them) in the global config changes the default. Framework-provided commands (store, built-in, user overlay) run without this prompt. @@ -84,9 +84,9 @@ commands: # `disabled: true` is only meaningful in .lerd.yaml; ignored in framework yamls ``` -**Known icons**: `broom`, `database`, `refresh`, `link`, `check`, `list`, `key`, `edit`, `arrow-down`, `arrow-up`, `play`, `terminal`. An unknown icon falls back to a generic glyph; `lerd check` warns. +**Known icons**: `broom`, `database`, `refresh`, `link`, `check`, `list`, `key`, `edit`, `arrow-down`, `arrow-up`, `play`, `terminal`. An unknown icon falls back to a generic glyph; `lerd site:doctor` warns. -**Output values:** invalid values fail `lerd check`. Defaults to `text`. +**Output values:** invalid values fail `lerd site:doctor`. Defaults to `text`. **Check rules**: reuse `FrameworkRule`. The two common forms are `composer: ` (the package must be in `composer.json`) and `file: ` (the file must exist relative to the project root). @@ -99,7 +99,7 @@ When the lerd MCP server is registered, an AI assistant can: - `command_add(site, name, command, ...)`: write a new entry into `.lerd.yaml`'s `commands:` block. Same `name` as a framework default replaces it. Use `disabled: true` to suppress a framework default - `command_remove(site, name)`: delete a project entry -Agents should prefer `commands_run` over invoking `php artisan` / `drush` / `wp` directly so per-project overrides are honored, and `command_add` over hand-editing yaml so the entry passes the same validation `lerd check` runs. +Agents should prefer `commands_run` over invoking `php artisan` / `drush` / `wp` directly so per-project overrides are honored, and `command_add` over hand-editing yaml so the entry passes the same validation `lerd site:doctor` runs. ## CLI diff --git a/docs/usage/framework-definitions.md b/docs/usage/framework-definitions.md index 4d5bb890..c82101df 100644 --- a/docs/usage/framework-definitions.md +++ b/docs/usage/framework-definitions.md @@ -392,7 +392,7 @@ The `commands:` list is the framework's own verbs: the things you would otherwis `broom`, `database`, `refresh`, `link`, `check`, `list`, `key`, `edit`, `arrow-down`, `arrow-up`, `play`, `terminal` -`lerd check` validates a definition's commands, and it is the fastest way to catch a typo: an unknown `output` is an error, and an unknown `icon` is a warning. +`lerd site:doctor` validates a definition's commands, and it is the fastest way to catch a typo: an unknown `output` is an error, and an unknown `icon` is a warning. ## Declining a warning @@ -407,7 +407,7 @@ The repeated-query warning is the case that needs it. On a content management sy ## Doctor checks -The `doctor:` section adds framework-specific health checks to the ones every site gets for free (env file present, every picked service wired into it, dependencies installed and locked, audit clean, PHP version in range, nginx vhost current). They run on `lerd site:doctor` and in the dashboard's doctor panel. Keeping them declarative is what stops the doctor from growing a Go branch per framework. +The `doctor:` section adds framework-specific health checks to the ones every site gets for free (a valid `.lerd.yaml`, env file present, every picked service wired into it, dependencies installed and locked, audit clean, PHP version in range, nginx vhost current). They run on `lerd site:doctor` and in the dashboard's doctor panel. Keeping them declarative is what stops the doctor from growing a Go branch per framework. The section also takes a `migrate_command`, naming whichever of the framework's own `commands:` applies the schema. The universal database checks offer it as their fix, so an empty or missing database is reported with the button that fills it. Every framework spells it differently (Laravel `migrate`, Symfony `doctrine:migrations:migrate`, Drupal `updb`), so nothing but the definition can say; a framework that declares none, or names a command it does not have, gets a finding with no fix rather than a button that maps to nothing. diff --git a/docs/usage/host-proxy.md b/docs/usage/host-proxy.md index d8b1c81b..466d605b 100644 --- a/docs/usage/host-proxy.md +++ b/docs/usage/host-proxy.md @@ -41,7 +41,7 @@ Lerd starts the dev server, generates the proxy vhost, and serves the domain. Op ## Configuration -The `proxy` section in `.lerd.yaml` is mutually exclusive with `container:` (a site is one or the other, and `lerd check` rejects setting both). +The `proxy` section in `.lerd.yaml` is mutually exclusive with `container:` (a site is one or the other, and `lerd site:doctor` rejects setting both). | Field | Required | Default | Description | |-------|----------|---------|-------------| diff --git a/docs/usage/sites.md b/docs/usage/sites.md index 81dbca9f..bf37cf15 100644 --- a/docs/usage/sites.md +++ b/docs/usage/sites.md @@ -188,7 +188,7 @@ When `lerd link` runs and workers are configured but not yet running, it prompts Toggling workers from the CLI (`lerd queue:start`, `lerd schedule:stop`, etc.) or the web UI syncs the running state back to `.lerd.yaml` when the file exists. -`lerd check` validates that listed workers are valid for the detected framework. +`lerd site:doctor` validates that listed workers are valid for the detected framework. `lerd status` includes a Workers section showing all active, restarting, or failed workers across sites. In the web UI, failing workers show a pulsing red toggle and their log tab appears with a "!" indicator. diff --git a/internal/cli/check.go b/internal/cli/check.go index 2fd4ccdc..eeb353eb 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -1,323 +1,21 @@ package cli import ( - "fmt" - "os" - "path/filepath" - "slices" - - "github.com/geodro/lerd/internal/config" - "github.com/geodro/lerd/internal/feedback" - phpPkg "github.com/geodro/lerd/internal/php" - "github.com/geodro/lerd/internal/serviceops" "github.com/spf13/cobra" ) -// check-report markers, coloured once via the shared palette (plain when piped -// / NO_COLOR). ckOK/ckWarn/ckFail are printf wrappers so go vet keeps verifying -// the format strings at every call site. -var ( - ckOKGlyph = feedback.Green("✓") - ckWarnGlyph = feedback.Amber("⚠") - ckFailGlyph = feedback.Red("✗") -) - -func ckOK(format string, a ...any) { fmt.Printf(" %s %s", ckOKGlyph, fmt.Sprintf(format, a...)) } -func ckWarn(format string, a ...any) { fmt.Printf(" %s %s", ckWarnGlyph, fmt.Sprintf(format, a...)) } -func ckFail(format string, a ...any) { fmt.Printf(" %s %s", ckFailGlyph, fmt.Sprintf(format, a...)) } - -// NewCheckCmd returns the check command. +// NewCheckCmd returns the check command. Validating .lerd.yaml is now one check +// inside the site doctor, so this stays only as an alias for the muscle memory +// and points the user at the command that answers the whole question. func NewCheckCmd() *cobra.Command { return &cobra.Command{ - Use: "check", - Short: "Validate .lerd.yaml — PHP version, services, workers, container config, custom_workers, and db", - RunE: runCheck, - } -} - -func runCheck(_ *cobra.Command, _ []string) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - - path := filepath.Join(cwd, ".lerd.yaml") - if _, err := os.Stat(path); os.IsNotExist(err) { - return fmt.Errorf("no .lerd.yaml found in %s — run lerd init to create one", cwd) - } - - cfg, err := config.LoadProjectConfig(cwd) - if err != nil { - ckFail(".lerd.yaml has invalid YAML syntax\n") - fmt.Printf(" %v\n", err) - return fmt.Errorf("validation failed") - } - - warnings := 0 - errors := 0 - - // PHP version - if cfg.PHPVersion != "" { - if err := validatePHPVersion(cfg.PHPVersion); err != nil { - ckFail("php_version: %s — %v\n", cfg.PHPVersion, err) - errors++ - } else if !phpPkg.IsInstalled(cfg.PHPVersion) { - ckWarn("php_version: %s is not installed — run lerd php:install %s\n", cfg.PHPVersion, cfg.PHPVersion) - warnings++ - } else { - ckOK("php_version: %s\n", cfg.PHPVersion) - } - } - - // Node version - if cfg.NodeVersion != "" { - ckOK("node_version: %s\n", cfg.NodeVersion) - } - - // Request timeout - if cfg.RequestTimeout != 0 { - if cfg.RequestTimeout < 0 { - ckFail("request_timeout: %d — must be a positive number of seconds\n", cfg.RequestTimeout) - errors++ - } else { - ckOK("request_timeout: %ds\n", cfg.RequestTimeout) - } - } - - // Framework - if cfg.Framework != "" { - if cfg.FrameworkDef != nil { - ckOK("framework: %s (inline definition)\n", cfg.Framework) - } else if _, ok := config.GetFramework(cfg.Framework); ok { - ckOK("framework: %s\n", cfg.Framework) - } else { - ckWarn("framework: %q is not a known or user-defined framework\n", cfg.Framework) - warnings++ - } - } - - // Secured - if cfg.Secured { - ckOK("secured: true\n") - } - - // Domains - if len(cfg.Domains) > 0 { - ckOK("domains: %v\n", cfg.Domains) - } - - // Workers - if len(cfg.Workers) > 0 { - if cfg.Container != nil { - // Custom container site: workers must be defined in custom_workers. - for _, w := range cfg.Workers { - if _, ok := cfg.CustomWorkers[w]; ok || config.IsBuiltinWorker(w) { - ckOK("worker: %s\n", w) - } else { - ckFail("worker: %q is not defined in custom_workers\n", w) - errors++ - } - } - } else { - fwName := cfg.Framework - fw, hasFw := config.GetFrameworkForDir(fwName, cwd) - - hasQueue := false - hasHorizon := false - for _, w := range cfg.Workers { - if w == "queue" { - hasQueue = true - } - if w == "horizon" { - hasHorizon = true - } - - // Stripe and the host-proxy app worker are lerd built-ins, run - // through their own units and never declared by a framework. - if config.IsBuiltinWorker(w) { - ckOK("worker: %s\n", w) - continue - } - - if !hasFw || fw.Workers == nil { - if fwName != "" { - ckWarn("worker: %q — framework %s has no worker definitions\n", w, fwName) - warnings++ - } else { - ckWarn("worker: %q — no framework detected\n", w) - warnings++ - } - continue - } - wDef, ok := fw.Workers[w] - if !ok { - ckFail("worker: %q is not defined for framework %s\n", w, fwName) - errors++ - continue - } - if wDef.Check != nil && !config.MatchesRule(cwd, *wDef.Check) { - ckWarn("worker: %s — prerequisite not met (check rule failed)\n", w) - warnings++ - } else { - ckOK("worker: %s\n", w) - } - } - - if hasQueue && hasHorizon { - ckWarn("workers: both queue and horizon are listed — horizon manages queues, queue worker will be skipped\n") - warnings++ - } - if hasQueue && SiteHasHorizon(cwd) { - ckWarn("workers: queue is listed but laravel/horizon is installed — horizon will be started instead\n") - warnings++ - } - } - } - - // Services - for _, svc := range cfg.Services { - if svc.Custom != nil { - // Inline definition — check required fields. - if svc.Custom.Image == "" { - ckFail("service %q: inline definition is missing required \"image\" field\n", svc.Name) - errors++ - } else { - ckOK("service: %s (inline, image: %s)\n", svc.Name, svc.Custom.Image) - } - continue - } - - if svc.Preset != "" { - // Preset reference — verify the preset exists in the catalog, then - // check whether it has been installed on this machine. - if _, err := config.LoadPreset(svc.Preset); err != nil { - ckFail("service %q: unknown preset %q\n", svc.Name, svc.Preset) - errors++ - } else if _, err := config.LoadCustomService(svc.Name); err != nil { - ckWarn("service %s: preset %q not installed — run: lerd service preset install %s\n", svc.Name, svc.Preset, svc.Preset) - warnings++ - } else { - ckOK("service: %s (preset: %s)\n", svc.Name, svc.Preset) - } - continue - } - - if isKnownService(svc.Name) { - ckOK("service: %s\n", svc.Name) - continue - } - - if serviceops.ServiceInstalled(svc.Name) { - ckOK("service: %s (custom)\n", svc.Name) - } else { - ckFail("service %q: not installed — run `lerd service preset install %s` (if it's a bundled preset) or `lerd service add --name %s ...`\n", - svc.Name, svc.Name, svc.Name) - errors++ - } - } - - // Container - if cfg.Container != nil { - if cfg.Container.Port <= 0 || cfg.Container.Port > 65535 { - ckFail("container.port: required and must be 1–65535\n") - errors++ - } else { - ckOK("container.port: %d\n", cfg.Container.Port) - } - cfPath := cfg.Container.Containerfile - if cfPath == "" { - cfPath = "Containerfile.lerd" - } - if _, err := os.Stat(filepath.Join(cwd, cfPath)); os.IsNotExist(err) { - ckWarn("container.containerfile: %s not found — lerd link will fail\n", cfPath) - warnings++ - } else { - ckOK("container.containerfile: %s\n", cfPath) - } - if cfg.Container.BuildContext != "" { - if _, err := os.Stat(filepath.Join(cwd, cfg.Container.BuildContext)); os.IsNotExist(err) { - ckWarn("container.build_context: %s not found\n", cfg.Container.BuildContext) - warnings++ - } else { - ckOK("container.build_context: %s\n", cfg.Container.BuildContext) - } - } - if cfg.Container.SSL { - ckOK("container.ssl: true (nginx will proxy_pass via HTTPS with ssl_verify off)\n") - } - } - - // custom_workers - for name, w := range cfg.CustomWorkers { - if w.Command == "" { - ckFail("custom_worker.%s: command is required\n", name) - errors++ - } else { - ckOK("custom_worker.%s\n", name) - } - } - - // commands - seenCmdNames := map[string]bool{} - for i, c := range cfg.Commands { - if c.Name == "" { - ckFail("commands[%d]: name is required\n", i) - errors++ - continue - } - if seenCmdNames[c.Name] { - ckFail("command %q: duplicate name\n", c.Name) - errors++ - continue - } - seenCmdNames[c.Name] = true - if c.Disabled { - ckOK("command.%s (disabled)\n", c.Name) - continue - } - if c.Command == "" { - ckFail("command %q: command is required (or set disabled: true)\n", c.Name) - errors++ - continue - } - if c.Label == "" { - ckWarn("command %q: label is empty, the UI will fall back to the name\n", c.Name) - warnings++ - } - if c.Output != "" && !slices.Contains(config.ValidCommandOutputs, c.Output) { - ckFail("command %q: output %q is invalid (expected: %v)\n", c.Name, c.Output, config.ValidCommandOutputs) - errors++ - continue - } - if c.Icon != "" && !slices.Contains(config.KnownCommandIcons, c.Icon) { - ckWarn("command %q: icon %q is not in the known set, UI will fall back to a generic icon\n", c.Name, c.Icon) - warnings++ - } - ckOK("command.%s\n", c.Name) - } - - // db - if cfg.DB.Service != "" { - if isKnownService(cfg.DB.Service) { - ckOK("db.service: %s\n", cfg.DB.Service) - } else if serviceops.ServiceInstalled(cfg.DB.Service) { - ckOK("db.service: %s (custom)\n", cfg.DB.Service) - } else { - ckFail("db.service: %q is not a known service\n", cfg.DB.Service) - errors++ - } - } - - // Summary - fmt.Println() - if errors > 0 { - fmt.Printf(" %d error(s), %d warning(s)\n", errors, warnings) - return fmt.Errorf("validation failed") - } - if warnings > 0 { - fmt.Printf(" %d warning(s), no errors\n", warnings) - } else { - fmt.Printf(" .lerd.yaml is valid\n") + Use: "check", + Short: "Validate .lerd.yaml (alias for lerd site:doctor)", + Deprecated: "use `lerd site:doctor`, which validates .lerd.yaml as part of the site's health report.", + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runSiteDoctor("", false, false) + }, } - return nil } diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index a23b4e5b..fdca7400 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -568,6 +568,27 @@ func runDoctorInto(w io.Writer, useColor bool) (DoctorReport, error) { } } + // ── Sites ──────────────────────────────────────────────────────────────── + // The broad command has to be broad: an environment that passes every check + // above while three sites are failing is not a healthy machine. Each site + // gets the cheap half of `lerd site:doctor`, which is named for the detail. + section = "Sites" + fmt.Fprintln(w, "\n[Sites]") + swept := sweepSites() + if len(swept) == 0 { + ok("no linked sites to check") + } + for _, s := range swept { + switch { + case s.Failures > 0: + fail("site "+s.Label, s.Summary, "run: lerd site:doctor "+s.Label) + case s.Warnings > 0: + warn("site "+s.Label, s.Summary+", run: lerd site:doctor "+s.Label) + default: + ok("site " + s.Label) + } + } + // ── Version Info ───────────────────────────────────────────────────────── section = "Version Info" fmt.Fprintln(w, "\n[Version Info]") diff --git a/internal/cli/doctor_sites.go b/internal/cli/doctor_sites.go new file mode 100644 index 00000000..e4eefbf9 --- /dev/null +++ b/internal/cli/doctor_sites.go @@ -0,0 +1,114 @@ +package cli + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/geodro/lerd/internal/config" + "github.com/geodro/lerd/internal/sitedoctor" +) + +// maxSiteSweepConcurrency bounds how many sites the doctor diagnoses at once. +// The quick pass is file reads and unit-state probes, so this is about not +// stampeding the service manager rather than about CPU. +const maxSiteSweepConcurrency = 8 + +// siteSweepResult is one site's line in the doctor's site sweep. +type siteSweepResult struct { + Label string + Failures int + Warnings int + Summary string +} + +// sitesForSweep is a seam tests replace, so the sweep can be exercised without a +// site registry on the machine running the tests. +var sitesForSweep = func() []config.Site { + reg, err := config.LoadSites() + if err != nil { + return nil + } + return reg.Sites +} + +// quickSiteReport is the per-site diagnosis, hooked for the same reason. +var quickSiteReport = func(path, fwName string) sitedoctor.Response { + return sitedoctor.RunQuickForPath(context.Background(), path, fwName) +} + +// sweepSites runs the cheap half of the site doctor over every linked site, +// skipping the ignored ones and those with nothing to check. Results keep the +// registry's order so two runs on an unchanged machine read the same. +func sweepSites() []siteSweepResult { + var targets []config.Site + for _, s := range sitesForSweep() { + if s.Ignored || !sitedoctor.AppliesForPath(s.Path, s.Framework) { + continue + } + targets = append(targets, s) + } + + out := make([]siteSweepResult, len(targets)) + sem := make(chan struct{}, maxSiteSweepConcurrency) + var wg sync.WaitGroup + for i, s := range targets { + wg.Add(1) + go func(i int, s config.Site) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + out[i] = summariseSiteReport(siteSweepLabel(s), quickSiteReport(s.Path, s.Framework)) + }(i, s) + } + wg.Wait() + return out +} + +// siteSweepLabel is what the user types after `lerd site:doctor`, so the pointer +// in the finding is a command they can paste. +func siteSweepLabel(s config.Site) string { + if d := s.PrimaryDomain(); d != "" { + return d + } + return s.Name +} + +// summariseSiteReport reduces a site's report to one line: the counts, and the +// first failing check named so the user knows what they are about to look at. +func summariseSiteReport(label string, resp sitedoctor.Response) siteSweepResult { + res := siteSweepResult{Label: label, Failures: resp.Failures, Warnings: resp.Warnings} + if res.Failures == 0 && res.Warnings == 0 { + return res + } + var parts []string + if res.Failures > 0 { + parts = append(parts, fmt.Sprintf("%d failing", res.Failures)) + } + if res.Warnings > 0 { + parts = append(parts, fmt.Sprintf("%d warning", res.Warnings)) + } + summary := strings.Join(parts, ", ") + if first := firstProblem(resp); first != "" { + summary += " (" + first + ")" + } + res.Summary = summary + return res +} + +// firstProblem names the first failing check, falling back to the first warning. +func firstProblem(resp sitedoctor.Response) string { + for _, want := range []string{sitedoctor.StatusFail, sitedoctor.StatusWarn} { + for _, c := range resp.Checks { + if c.Status != want { + continue + } + if c.Label != "" { + return c.Label + } + return c.Name + } + } + return "" +} diff --git a/internal/cli/doctor_sites_test.go b/internal/cli/doctor_sites_test.go new file mode 100644 index 00000000..d0ec6af5 --- /dev/null +++ b/internal/cli/doctor_sites_test.go @@ -0,0 +1,61 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/geodro/lerd/internal/config" + "github.com/geodro/lerd/internal/sitedoctor" +) + +// stubSiteSweep points the sweep at fixed sites and reports, and restores the +// real lookups when the test ends. +func stubSiteSweep(t *testing.T, sites []config.Site, reports map[string]sitedoctor.Response) { + t.Helper() + prevSites, prevReport := sitesForSweep, quickSiteReport + sitesForSweep = func() []config.Site { return sites } + quickSiteReport = func(path, _ string) sitedoctor.Response { return reports[path] } + t.Cleanup(func() { sitesForSweep, quickSiteReport = prevSites, prevReport }) +} + +// The sweep skips a site the user has ignored and summarises the rest in +// registry order, naming the first failing check so the line says what is wrong. +func TestSweepSites(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "composer.json", "{}") // gives the site something to check + sites := []config.Site{ + {Name: "acme", Path: dir, Domains: []string{"acme.test"}}, + {Name: "old", Path: dir, Domains: []string{"old.test"}, Ignored: true}, + } + stubSiteSweep(t, sites, map[string]sitedoctor.Response{ + dir: { + Failures: 1, Warnings: 2, + Checks: []sitedoctor.Check{ + {Name: "node_deps", Status: sitedoctor.StatusWarn}, + {Name: "server_database", Label: "Database", Status: sitedoctor.StatusFail}, + }, + }, + }) + + got := sweepSites() + if len(got) != 1 { + t.Fatalf("want only the non-ignored site, got %d results", len(got)) + } + if got[0].Label != "acme.test" { + t.Errorf("label: got %q, want the primary domain", got[0].Label) + } + if !strings.Contains(got[0].Summary, "1 failing") || !strings.Contains(got[0].Summary, "2 warning") { + t.Errorf("summary %q should carry both counts", got[0].Summary) + } + if !strings.Contains(got[0].Summary, "Database") { + t.Errorf("summary %q should name the failing check, not the warning", got[0].Summary) + } +} + +// A healthy site gets a bare line: no counts, nothing to point at. +func TestSummariseSiteReport_Healthy(t *testing.T) { + res := summariseSiteReport("acme.test", sitedoctor.Response{}) + if res.Summary != "" || res.Failures != 0 || res.Warnings != 0 { + t.Errorf("got %+v, want an empty summary", res) + } +} diff --git a/internal/config/framework.go b/internal/config/framework.go index fe595ec7..1299d7c1 100644 --- a/internal/config/framework.go +++ b/internal/config/framework.go @@ -327,7 +327,7 @@ const ( var ValidCommandOutputs = []string{CommandOutputSilent, CommandOutputText, CommandOutputURL, CommandOutputTerminal} // KnownCommandIcons is the curated icon vocabulary. .lerd.yaml entries with an -// icon outside this set fail `lerd check`. Keep in sync with the UI Icon +// icon outside this set warn in `lerd site:doctor`. Keep in sync with the UI Icon // component so an icon present here always resolves to a visual on screen. var KnownCommandIcons = []string{ "broom", "database", "refresh", "link", "check", "list", diff --git a/internal/sitedoctor/projectconfig.go b/internal/sitedoctor/projectconfig.go new file mode 100644 index 00000000..0661d7a2 --- /dev/null +++ b/internal/sitedoctor/projectconfig.go @@ -0,0 +1,231 @@ +package sitedoctor + +import ( + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/geodro/lerd/internal/config" + phpkg "github.com/geodro/lerd/internal/php" +) + +// projectConfigFile is the per-project config every check here reads. +const projectConfigFile = ".lerd.yaml" + +// checkProjectConfig validates the project's .lerd.yaml: the PHP version, the +// workers, the services, the container block and the commands. It is what `lerd +// check` used to be, folded in so one report answers whether the site is healthy +// instead of the user running a second command to find out. +func checkProjectConfig(path string, fw *config.Framework) (Check, bool) { + if !fileExists(filepath.Join(path, projectConfigFile)) { + return Check{}, false + } + problems, warnings := ValidateProjectConfig(path, fw) + switch { + case len(problems) > 0: + return Check{Name: "project_config", Status: StatusFail, Detail: joinProblems(problems, warnings)}, true + case len(warnings) > 0: + return Check{Name: "project_config", Status: StatusWarn, Detail: joinProblems(nil, warnings)}, true + default: + return Check{Name: "project_config", Status: StatusOK}, true + } +} + +func joinProblems(problems, warnings []string) string { + all := append(append([]string{}, problems...), warnings...) + return strings.Join(all, " · ") +} + +// ValidateProjectConfig reads the project's .lerd.yaml and returns what is wrong +// with it: problems that stop the site from coming up, and warnings that only +// degrade it. fw is the framework already resolved for the project, or nil to +// resolve it from the file. Both results are empty for a valid file. +func ValidateProjectConfig(path string, fw *config.Framework) (problems, warnings []string) { + cfg, err := config.LoadProjectConfig(path) + if err != nil { + return []string{fmt.Sprintf("%s is not valid YAML: %v", projectConfigFile, err)}, nil + } + + problem := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) } + warn := func(format string, a ...any) { warnings = append(warnings, fmt.Sprintf(format, a...)) } + + if cfg.PHPVersion != "" { + switch { + case !validPHPVersion(cfg.PHPVersion): + problem("php_version: %q is not a MAJOR.MINOR version", cfg.PHPVersion) + case !phpkg.IsInstalled(cfg.PHPVersion): + warn("php_version: %s is not installed, run lerd php:install %s", cfg.PHPVersion, cfg.PHPVersion) + } + } + if cfg.RequestTimeout < 0 { + problem("request_timeout: %d must be a positive number of seconds", cfg.RequestTimeout) + } + if cfg.Framework != "" && cfg.FrameworkDef == nil { + if _, ok := config.GetFramework(cfg.Framework); !ok { + warn("framework: %q is not a known or user-defined framework", cfg.Framework) + } + } + + p, w := validateWorkers(path, cfg, fw) + problems, warnings = append(problems, p...), append(warnings, w...) + problems = append(problems, validateServices(cfg)...) + p, w = validateContainer(path, cfg) + problems, warnings = append(problems, p...), append(warnings, w...) + p, w = validateCommands(cfg) + problems, warnings = append(problems, p...), append(warnings, w...) + + // Sorted, so two runs on an unchanged file read the same. + for _, name := range slices.Sorted(maps.Keys(cfg.CustomWorkers)) { + if cfg.CustomWorkers[name].Command == "" { + problems = append(problems, fmt.Sprintf("custom_worker.%s: command is required", name)) + } + } + return problems, warnings +} + +func validateWorkers(path string, cfg *config.ProjectConfig, fw *config.Framework) (problems, warnings []string) { + if len(cfg.Workers) == 0 { + return nil, nil + } + // A custom-container site runs its workers from custom_workers, so the + // framework's worker definitions never apply to it. + if cfg.Container != nil { + for _, w := range cfg.Workers { + if _, ok := cfg.CustomWorkers[w]; !ok && !config.IsBuiltinWorker(w) { + problems = append(problems, fmt.Sprintf("worker: %q is not defined in custom_workers", w)) + } + } + return problems, nil + } + + if fw == nil { + fw, _ = config.GetFrameworkForDir(cfg.Framework, path) + } + hasQueue, hasHorizon := false, false + for _, w := range cfg.Workers { + hasQueue = hasQueue || w == "queue" + hasHorizon = hasHorizon || w == "horizon" + // A worker the project defines itself is its own definition, whether or + // not the site runs a custom container. + if _, ok := cfg.CustomWorkers[w]; ok || config.IsBuiltinWorker(w) { + continue + } + if fw == nil || fw.Workers == nil { + warnings = append(warnings, fmt.Sprintf("worker: %q has no worker definitions to match", w)) + continue + } + wDef, ok := fw.Workers[w] + if !ok { + problems = append(problems, fmt.Sprintf("worker: %q is not defined for framework %s", w, cfg.Framework)) + continue + } + if wDef.Check != nil && !config.MatchesRule(path, *wDef.Check) { + warnings = append(warnings, fmt.Sprintf("worker: %s prerequisite not met (check rule failed)", w)) + } + } + if hasQueue && hasHorizon { + warnings = append(warnings, "workers: both queue and horizon are listed, horizon manages queues so the queue worker is skipped") + } + if hasQueue && siteHasHorizon(path) { + warnings = append(warnings, "workers: queue is listed but laravel/horizon is installed, horizon is started instead") + } + return problems, warnings +} + +// validateServices judges how the services are declared, not whether they are +// installed: the required_services check already reports a missing one, and does +// it with a fix attached. +func validateServices(cfg *config.ProjectConfig) (problems []string) { + for _, svc := range cfg.Services { + switch { + case svc.Custom != nil: + if svc.Custom.Image == "" { + problems = append(problems, fmt.Sprintf("service %q: the inline definition has no image", svc.Name)) + } + case svc.Preset != "": + if _, err := config.LoadPreset(svc.Preset); err != nil { + problems = append(problems, fmt.Sprintf("service %q: unknown preset %q", svc.Name, svc.Preset)) + } + } + } + return problems +} + +func validateContainer(path string, cfg *config.ProjectConfig) (problems, warnings []string) { + if cfg.Container == nil { + return nil, nil + } + if cfg.Container.Port <= 0 || cfg.Container.Port > 65535 { + problems = append(problems, "container.port: required, and must be 1-65535") + } + cf := cfg.Container.Containerfile + if cf == "" { + cf = "Containerfile.lerd" + } + if !fileExists(filepath.Join(path, cf)) { + warnings = append(warnings, fmt.Sprintf("container.containerfile: %s not found, lerd link will fail", cf)) + } + if bc := cfg.Container.BuildContext; bc != "" { + if _, err := os.Stat(filepath.Join(path, bc)); err != nil { + warnings = append(warnings, fmt.Sprintf("container.build_context: %s not found", bc)) + } + } + return problems, warnings +} + +func validateCommands(cfg *config.ProjectConfig) (problems, warnings []string) { + seen := map[string]bool{} + for i, c := range cfg.Commands { + if c.Name == "" { + problems = append(problems, fmt.Sprintf("commands[%d]: name is required", i)) + continue + } + if seen[c.Name] { + problems = append(problems, fmt.Sprintf("command %q: duplicate name", c.Name)) + continue + } + seen[c.Name] = true + if c.Disabled { + continue + } + if c.Command == "" { + problems = append(problems, fmt.Sprintf("command %q: command is required (or set disabled: true)", c.Name)) + continue + } + if c.Output != "" && !slices.Contains(config.ValidCommandOutputs, c.Output) { + problems = append(problems, fmt.Sprintf("command %q: output %q is invalid (expected: %v)", c.Name, c.Output, config.ValidCommandOutputs)) + continue + } + if c.Label == "" { + warnings = append(warnings, fmt.Sprintf("command %q: label is empty, the UI falls back to the name", c.Name)) + } + if c.Icon != "" && !slices.Contains(config.KnownCommandIcons, c.Icon) { + warnings = append(warnings, fmt.Sprintf("command %q: icon %q is not in the known set, the UI falls back to a generic one", c.Name, c.Icon)) + } + } + return problems, warnings +} + +// validPHPVersion reports whether s looks like MAJOR.MINOR, rejecting "8,5" and +// plain words the way the init prompt does. +func validPHPVersion(s string) bool { + parts := strings.SplitN(s, ".", 2) + if len(parts) != 2 { + return false + } + for _, p := range parts { + if p == "" || strings.TrimLeft(p, "0123456789") != "" { + return false + } + } + return true +} + +// siteHasHorizon reports whether composer.json lists laravel/horizon. +func siteHasHorizon(path string) bool { + data, err := os.ReadFile(filepath.Join(path, "composer.json")) + return err == nil && strings.Contains(string(data), `"laravel/horizon"`) +} diff --git a/internal/sitedoctor/projectconfig_test.go b/internal/sitedoctor/projectconfig_test.go new file mode 100644 index 00000000..2b608c37 --- /dev/null +++ b/internal/sitedoctor/projectconfig_test.go @@ -0,0 +1,64 @@ +package sitedoctor + +import ( + "strings" + "testing" +) + +// A project with no .lerd.yaml has nothing to validate, so the check stays out +// of the report rather than reporting a pass it never made. +func TestCheckProjectConfig_AbsentFile(t *testing.T) { + if _, ok := checkProjectConfig(t.TempDir(), nil); ok { + t.Error("expected no project_config check without a .lerd.yaml") + } +} + +func TestCheckProjectConfig_Valid(t *testing.T) { + dir := t.TempDir() + writeEnv(t, dir, ".lerd.yaml", "php_version: \"8.4\"\n") + c, ok := checkProjectConfig(dir, nil) + if !ok || c.Status == StatusFail { + t.Fatalf("got ok=%v status=%q detail=%q, want a non-failing check", ok, c.Status, c.Detail) + } +} + +// The findings `lerd check` used to print now land in the site report, so the +// fold doesn't lose the validation it was the only source of. +func TestCheckProjectConfig_ReportsInvalidConfig(t *testing.T) { + dir := t.TempDir() + writeEnv(t, dir, ".lerd.yaml", "php_version: \"8,5\"\ncommands:\n - name: seed\n") + c, ok := checkProjectConfig(dir, nil) + if !ok || c.Status != StatusFail { + t.Fatalf("got ok=%v status=%q, want a failing check", ok, c.Status) + } + for _, want := range []string{"php_version", "seed"} { + if !strings.Contains(c.Detail, want) { + t.Errorf("detail %q does not mention %q", c.Detail, want) + } + } +} + +// A worker the project defines itself is defined, whether or not the site runs a +// custom container: only the container branch used to look at custom_workers, so +// a plain site's own worker was reported as having no definition to match. +func TestValidateProjectConfig_CustomWorkerOnAPlainSite(t *testing.T) { + dir := t.TempDir() + writeEnv(t, dir, ".lerd.yaml", "workers:\n - reverb\ncustom_workers:\n reverb:\n command: php artisan reverb:start\n") + problems, warnings := ValidateProjectConfig(dir, nil) + if len(problems) != 0 || len(warnings) != 0 { + t.Errorf("got problems=%v warnings=%v, want none", problems, warnings) + } +} + +// A command with no label still works, so it is a warning rather than a problem. +func TestValidateProjectConfig_LabellessCommandWarnsOnly(t *testing.T) { + dir := t.TempDir() + writeEnv(t, dir, ".lerd.yaml", "commands:\n - name: seed\n command: php artisan db:seed\n") + problems, warnings := ValidateProjectConfig(dir, nil) + if len(problems) != 0 { + t.Errorf("unexpected problems: %v", problems) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "label") { + t.Errorf("warnings: got %v, want one about the empty label", warnings) + } +} diff --git a/internal/sitedoctor/quick_test.go b/internal/sitedoctor/quick_test.go new file mode 100644 index 00000000..28e4c4ed --- /dev/null +++ b/internal/sitedoctor/quick_test.go @@ -0,0 +1,77 @@ +package sitedoctor + +import ( + "context" + "path/filepath" + "testing" + + "github.com/geodro/lerd/internal/config" +) + +func statusByName(resp Response) map[string]string { + out := map[string]string{} + for _, c := range resp.Checks { + out[c.Name] = c.Status + } + return out +} + +// The quick pass is what `lerd doctor` sweeps every site with, so it must leave +// out everything that shells into the site: the framework command checks and the +// composer audit. What it keeps is the file-level dependency state. +func TestRunWithQuick_LeavesOutTheContainerChecks(t *testing.T) { + dir := t.TempDir() + writeEnv(t, dir, ".env", "APP_KEY=base64:key\n") + writeEnv(t, dir, "composer.json", "{}") + writeEnv(t, dir, "composer.lock", "{}") + mustMkdir(t, filepath.Join(dir, "vendor")) + fw := &config.Framework{ + Name: "laravel", + Env: config.FrameworkEnvConf{File: ".env"}, + Doctor: &config.FrameworkDoctor{Checks: []config.DoctorCheck{ + {Name: "migrations", Type: "command", Command: "echo Pending", FailIfOutputContains: "Pending"}, + }}, + } + + quick := statusByName(RunWith(context.Background(), dir, fw, Options{Quick: true})) + if _, ran := quick["migrations"]; ran { + t.Error("quick must skip the framework command checks") + } + if _, ran := quick["composer_audit"]; ran { + t.Error("quick must skip the composer audit") + } + if quick["composer_deps"] != StatusOK { + t.Errorf("composer_deps: got %q, want the file-level check to still run", quick["composer_deps"]) + } +} + +// The full report keeps running the command checks the quick one drops. +func TestRunWithFull_StillRunsTheCommandChecks(t *testing.T) { + dir := t.TempDir() + writeEnv(t, dir, ".env", "APP_KEY=base64:key\n") + fw := &config.Framework{ + Name: "laravel", + Env: config.FrameworkEnvConf{File: ".env"}, + Doctor: &config.FrameworkDoctor{Checks: []config.DoctorCheck{ + {Name: "migrations", Type: "command", Command: "echo Pending", FailIfOutputContains: "Pending"}, + }}, + } + if got := statusByName(Run(context.Background(), dir, fw))["migrations"]; got != StatusFail { + t.Errorf("migrations: got %q, want the command check to run and fail", got) + } +} + +// A missing vendor directory is the one composer finding the quick pass can +// still make on its own. +func TestQuickComposerDeps_FlagsMissingVendor(t *testing.T) { + dir := t.TempDir() + writeEnv(t, dir, "composer.json", "{}") + tasks := dependencyCheckTasks(context.Background(), dir, nil, Options{Quick: true}) + if len(tasks) != 1 { + t.Fatalf("want 1 quick composer task, got %d", len(tasks)) + } + c, _ := tasks[0]() + if c.Status != StatusWarn || c.Fix != FixComposerInstall { + t.Errorf("got status=%q fix=%q, want warn/%s", c.Status, c.Fix, FixComposerInstall) + } +} diff --git a/internal/sitedoctor/server_database.go b/internal/sitedoctor/server_database.go index 14682ce8..32b34a59 100644 --- a/internal/sitedoctor/server_database.go +++ b/internal/sitedoctor/server_database.go @@ -3,6 +3,8 @@ package sitedoctor import ( "fmt" "strings" + "sync" + "time" "github.com/geodro/lerd/internal/config" "github.com/geodro/lerd/internal/serviceops" @@ -27,7 +29,47 @@ var listDatabases = func(service string) ([]string, error) { func stubDatabaseLister(fn func(string) ([]string, error)) func() { prev := listDatabases listDatabases = fn - return func() { listDatabases = prev } + forgetDatabases() + return func() { + listDatabases = prev + forgetDatabases() + } +} + +// dbListTTL bounds how long one engine's database list is reused. `lerd doctor` +// sweeps every site, and without this each one pays its own container exec to +// ask the same engine the same question. +const dbListTTL = 5 * time.Second + +type dbListEntry struct { + names []string + err error + at time.Time +} + +var dbListCache = struct { + sync.Mutex + entries map[string]dbListEntry +}{entries: map[string]dbListEntry{}} + +// cachedDatabases is listDatabases with the recent answer reused. The lock is +// held across the lookup on purpose: concurrent callers asking for the same +// engine wait for the one exec instead of each starting their own. +func cachedDatabases(service string) ([]string, error) { + dbListCache.Lock() + defer dbListCache.Unlock() + if e, ok := dbListCache.entries[service]; ok && time.Since(e.at) < dbListTTL { + return e.names, e.err + } + names, err := listDatabases(service) + dbListCache.entries[service] = dbListEntry{names: names, err: err, at: time.Now()} + return names, err +} + +func forgetDatabases() { + dbListCache.Lock() + defer dbListCache.Unlock() + dbListCache.entries = map[string]dbListEntry{} } // checkServerDatabase fails when the site's database does not exist on the @@ -49,7 +91,7 @@ func checkServerDatabase(path string, fw *config.Framework) (Check, bool) { } checked := false for _, t := range targets { - names, err := listDatabases(t.Service) + names, err := cachedDatabases(t.Service) if err != nil { // The engine is down or unreachable. Reporting that as a missing // schema would send the user to create a database that may exist. diff --git a/internal/sitedoctor/server_database_declared_test.go b/internal/sitedoctor/server_database_declared_test.go index ac37fc7e..69ac56a3 100644 --- a/internal/sitedoctor/server_database_declared_test.go +++ b/internal/sitedoctor/server_database_declared_test.go @@ -62,3 +62,26 @@ func TestCheckServerDatabase_readsAPHPSettingsFile(t *testing.T) { t.Errorf("check = %+v, want ok once the database exists", c) } } + +// Asking one engine for its databases costs a container exec, and the doctor's +// site sweep asks the same engine once per site, so the answer is reused. +func TestCachedDatabases_ReusesOneLookupPerEngine(t *testing.T) { + calls := 0 + restore := stubDatabaseLister(func(string) ([]string, error) { + calls++ + return []string{"shop"}, nil + }) + defer restore() + + for range 3 { + if names, err := cachedDatabases("mysql"); err != nil || len(names) != 1 { + t.Fatalf("got %v, %v", names, err) + } + } + if calls != 1 { + t.Errorf("looked the engine up %d times, want 1", calls) + } + if _, err := cachedDatabases("postgres"); err != nil || calls != 2 { + t.Errorf("a second engine must be its own lookup: calls=%d err=%v", calls, err) + } +} diff --git a/internal/sitedoctor/sitedoctor.go b/internal/sitedoctor/sitedoctor.go index d19b43a2..84486aec 100644 --- a/internal/sitedoctor/sitedoctor.go +++ b/internal/sitedoctor/sitedoctor.go @@ -112,20 +112,26 @@ func (d *Response) add(c Check) { d.Checks = append(d.Checks, c) } -// Run builds the doctor report for the project at path using fw to drive both -// the universal baseline and the framework's declarative checks. fw may be nil -// (an unknown framework) — only the file/dependency baseline runs then. The -// cheap checks read files; command and audit checks touch the container. // RunForPath resolves the framework definition for a project path and runs the -// doctor, so the CLI, MCP, and Web UI share one path -> framework -> Run chain -// instead of re-deriving it three ways. fwName is the site's recorded framework -// when known; pass "" to detect it from the path. +// doctor, so the CLI, MCP, and Web UI share one path -> framework -> Run chain. +// fwName is the site's recorded framework; pass "" to detect it from the path. func RunForPath(ctx context.Context, path, fwName string) Response { + return runForPath(ctx, path, fwName, Options{}) +} + +// RunQuickForPath is RunForPath without the checks that shell into the site +// container or read the timing snapshot, so `lerd doctor` can sweep every linked +// site without turning into a minute-long command. +func RunQuickForPath(ctx context.Context, path, fwName string) Response { + return runForPath(ctx, path, fwName, Options{Quick: true}) +} + +func runForPath(ctx context.Context, path, fwName string, opts Options) Response { if fwName == "" { fwName, _ = config.DetectFrameworkForDir(path) } fw, _ := config.GetFrameworkForDir(fwName, path) - return Run(ctx, path, fw) + return RunWith(ctx, path, fw, opts) } // AppliesForPath reports whether the doctor has any check to run for the project @@ -160,6 +166,11 @@ func Applies(path string, fw *config.Framework) bool { if fileExists(filepath.Join(path, "package.json")) { return true } + // A .lerd.yaml is itself something to validate, so a project carrying one + // always has a report worth opening. + if fileExists(filepath.Join(path, projectConfigFile)) { + return true + } // A committed dotenv example drives the env_drift check even with no framework, // so a bare proxy carrying one still has something to report. if _, format, exampleFile := envSetup(fw, path); format == "dotenv" && fileExists(filepath.Join(path, exampleFile)) { @@ -168,8 +179,27 @@ func Applies(path string, fw *config.Framework) bool { return false } +// Options tunes how much of the report Run produces. +type Options struct { + // Quick drops the checks that shell into the site container (the framework + // command checks, composer validate, the composer and npm audits) and the + // request-timing lookup, leaving the file-and-config ones. + Quick bool +} + +// Run builds the doctor report for the project at path using fw to drive both +// the universal baseline and the framework's declarative checks. fw may be nil +// (an unknown framework), and then only the file/dependency baseline runs. func Run(ctx context.Context, path string, fw *config.Framework) Response { + return RunWith(ctx, path, fw, Options{}) +} + +// RunWith is Run honouring opts, so a caller can ask for the cheap checks only. +func RunWith(ctx context.Context, path string, fw *config.Framework, opts Options) Response { resp := Response{Checks: []Check{}} + if c, ok := checkProjectConfig(path, fw); ok { + resp.add(c) + } envFile, envFormat, exampleFile := envSetup(fw, path) envPath := filepath.Join(path, envFile) @@ -226,6 +256,11 @@ func Run(ctx context.Context, path string, fw *config.Framework) Response { var tasks []func() (Check, bool) for _, spec := range frameworkChecks(fw) { spec := spec + // A command check is an exec into the site's container, which is what the + // quick pass exists to avoid. + if opts.Quick && spec.Type == "command" { + continue + } // A known-broken database turns a migration check into "couldn't run" noise // that just repeats the database finding's remedy, so skip a command check // whose fix is the same migrate command; unrelated command checks still run. @@ -234,7 +269,7 @@ func Run(ctx context.Context, path string, fw *config.Framework) Response { } tasks = append(tasks, func() (Check, bool) { return runDeclaredCheck(ctx, path, envPath, envFormat, spec) }) } - tasks = append(tasks, dependencyCheckTasks(ctx, path, fw)...) + tasks = append(tasks, dependencyCheckTasks(ctx, path, fw, opts)...) for _, c := range runChecksConcurrently(tasks) { resp.add(c) } @@ -244,8 +279,10 @@ func Run(ctx context.Context, path string, fw *config.Framework) Response { if c, ok := checkVhost(path); ok { resp.add(c) } - if c, ok := checkSlowRoutes(path); ok { - resp.add(c) + if !opts.Quick { + if c, ok := checkSlowRoutes(path); ok { + resp.add(c) + } } applyLabels(&resp) return resp @@ -458,6 +495,7 @@ func runDeclaredCheck(ctx context.Context, path, envPath, envFormat string, spec // universalLabels maps the built-in check names to their display labels. The // declared framework checks carry their own labels from the store. var universalLabels = map[string]string{ + "project_config": "Project Config", "required_services": "Required Services", "env_present": "Env File", "service_wiring": "Service Wiring", @@ -994,17 +1032,23 @@ func runChecksConcurrently(tasks []func() (Check, bool)) []Check { // tasks. Each is skipped when its manifest is absent, and the composer audit is // skipped when vendor/ is missing (checkComposerDeps already flags that, and the // audit can only degrade to "unknown" without installed packages). -func dependencyCheckTasks(ctx context.Context, path string, fw *config.Framework) []func() (Check, bool) { +func dependencyCheckTasks(ctx context.Context, path string, fw *config.Framework, opts Options) []func() (Check, bool) { var tasks []func() (Check, bool) if fileExists(filepath.Join(path, "composer.json")) && !composerDisabled(fw) { - tasks = append(tasks, func() (Check, bool) { return checkComposerDeps(ctx, path), true }) - if dirExists(filepath.Join(path, "vendor")) { - tasks = append(tasks, func() (Check, bool) { return checkComposerAudit(ctx, path), true }) + if opts.Quick { + tasks = append(tasks, func() (Check, bool) { return checkComposerDepsFiles(path), true }) + } else { + tasks = append(tasks, func() (Check, bool) { return checkComposerDeps(ctx, path), true }) + if dirExists(filepath.Join(path, "vendor")) { + tasks = append(tasks, func() (Check, bool) { return checkComposerAudit(ctx, path), true }) + } } } if fileExists(filepath.Join(path, "package.json")) { tasks = append(tasks, func() (Check, bool) { return checkNodeDeps(path), true }) - tasks = append(tasks, func() (Check, bool) { return checkNodeAudit(ctx, path), true }) + if !opts.Quick { + tasks = append(tasks, func() (Check, bool) { return checkNodeAudit(ctx, path), true }) + } } return tasks } @@ -1019,11 +1063,8 @@ func composerDisabled(fw *config.Framework) bool { // lock file has drifted from composer.json. Degrades to "unknown" when composer // can't run. func checkComposerDeps(ctx context.Context, path string) Check { - if !dirExists(filepath.Join(path, "vendor")) { - return Check{Name: "composer_deps", Status: StatusWarn, Detail: "Composer dependencies aren't installed, run composer install.", Fix: FixComposerInstall} - } - if !fileExists(filepath.Join(path, "composer.lock")) { - return Check{Name: "composer_deps", Status: StatusWarn, Detail: "No composer.lock is committed, run composer install to create one.", Fix: FixComposerInstall} + if c := checkComposerDepsFiles(path); c.Status != StatusOK { + return c } cctx, cancel := context.WithTimeout(ctx, commandTimeout) defer cancel() @@ -1037,6 +1078,19 @@ func checkComposerDeps(ctx context.Context, path string) Check { return Check{Name: "composer_deps", Status: StatusOK} } +// checkComposerDepsFiles is the part of the composer check that reads files +// only. Its OK is "nothing visibly wrong here", which the full check then puts +// to composer itself. +func checkComposerDepsFiles(path string) Check { + if !dirExists(filepath.Join(path, "vendor")) { + return Check{Name: "composer_deps", Status: StatusWarn, Detail: "Composer dependencies aren't installed, run composer install.", Fix: FixComposerInstall} + } + if !fileExists(filepath.Join(path, "composer.lock")) { + return Check{Name: "composer_deps", Status: StatusWarn, Detail: "No composer.lock is committed, run composer install to create one.", Fix: FixComposerInstall} + } + return Check{Name: "composer_deps", Status: StatusOK} +} + // composerLockStale reports whether `composer validate` flagged the lock file as // out of date with composer.json. func composerLockStale(output string) bool { diff --git a/internal/sitedoctor/sitedoctor_test.go b/internal/sitedoctor/sitedoctor_test.go index 6762c366..41d34537 100644 --- a/internal/sitedoctor/sitedoctor_test.go +++ b/internal/sitedoctor/sitedoctor_test.go @@ -718,7 +718,7 @@ func TestDependencyCheckTasks_SkipsComposerAuditWithoutVendor(t *testing.T) { t.Run("composer.json without vendor: deps only, no audit", func(t *testing.T) { dir := t.TempDir() writeEnv(t, dir, "composer.json", "{}") - if got := len(dependencyCheckTasks(context.Background(), dir, nil)); got != 1 { + if got := len(dependencyCheckTasks(context.Background(), dir, nil, Options{})); got != 1 { t.Errorf("want 1 task (deps only), got %d", got) } }) @@ -727,7 +727,7 @@ func TestDependencyCheckTasks_SkipsComposerAuditWithoutVendor(t *testing.T) { dir := t.TempDir() writeEnv(t, dir, "composer.json", "{}") mustMkdir(t, filepath.Join(dir, "vendor")) - if got := len(dependencyCheckTasks(context.Background(), dir, nil)); got != 2 { + if got := len(dependencyCheckTasks(context.Background(), dir, nil, Options{})); got != 2 { t.Errorf("want 2 tasks (deps + audit), got %d", got) } })