Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
| `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 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, which `--fix` and the dashboard's *Create the missing database* button create), 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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/usage/framework-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ The repeated-query warning is the case that needs it. On a content management sy

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 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.
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. A server database that does not exist at all is the exception: migrations have nowhere to run until the schema is there, so that finding carries a button that creates it and the migrate button returns on the re-check. 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.

Each check carries a `name` (a stable id), a `type` that selects the evaluator, an optional `label` for display, an optional `detail` that overrides the generated message, an optional `severity`, and an optional `fix`.

Expand Down
31 changes: 29 additions & 2 deletions internal/cli/site_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,28 @@ func readyDeclaredServices(path string, fw *config.Framework, quiet bool) (bool,
return changed, nil
}

// createMissingDatabases creates the databases the project points at that their
// engine does not hold, which is what a missing schema needs before migrations
// have anywhere to run. It reports whether anything was created and stops at the
// first failure rather than working through an engine that just refused.
func createMissingDatabases(path string, quiet bool) (bool, error) {
created := false
for _, t := range sitedoctor.MissingDatabases(path) {
if _, err := serviceops.CreateDatabase(t.Service, t.Database); err != nil {
return created, fmt.Errorf("creating %s on %s: %w", t.Database, t.Service, err)
}
created = true
if !quiet {
fmt.Printf(" %s\n\n", feedback.Dim("created the "+t.Database+" database on "+t.Service))
}
}
return created, nil
}

// applySiteDoctorFixes resolves the findings lerd can act on by itself and
// returns a fresh report: a drifted vhost is rewritten, and a service picked but
// not wired has its connection written. The composer and npm ones are left out;
// returns a fresh report: a drifted vhost is rewritten, a database the engine
// does not hold is created, and a service picked but not wired has its
// connection written. The composer and npm ones are left out;
// they run in the site's container behind a run lock and stream their output,
// which belongs to the surfaces that can show it.
func applySiteDoctorFixes(path, fwName string, resp sitedoctor.Response, quiet bool) sitedoctor.Response {
Expand All @@ -119,6 +138,14 @@ func applySiteDoctorFixes(path, fwName string, resp sitedoctor.Response, quiet b
if ready {
fixed = true
}
case sitedoctor.FixCreateDatabase:
created, err := createMissingDatabases(path, quiet)
if err != nil {
feedback.Warn("%v", err)
}
if created {
fixed = true
}
case sitedoctor.FixEnvSync:
if err := runLerdEnvTo(path, fixOutput(quiet)); err != nil {
feedback.Warn("writing the env: %v", err)
Expand Down
54 changes: 38 additions & 16 deletions internal/sitedoctor/server_database.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,46 @@ func stubDatabaseLister(fn func(string) ([]string, error)) func() {
// checked like any other. It used to read DB_CONNECTION, DB_HOST and DB_DATABASE
// by name, which are Laravel's, so the frameworks least likely to be wired that
// way were the ones it could not check.
func checkServerDatabase(path string, fw *config.Framework) (Check, bool) {
targets := config.DBTargetsFor(path)
if len(targets) == 0 {
// No lerd-run database: a file database, an external server, or nothing
// configured. None of those is this check's to judge.
//
// The fix creates the database rather than migrating it: migrations fail against
// a database the engine does not hold, so the schema has to exist first, and the
// migrate button returns on the re-check that follows.
func checkServerDatabase(path string) (Check, bool) {
missing, checked := missingDatabases(path)
if !checked {
// Either nothing could be asked of an engine, or the project points at no
// lerd-run database at all: a file database, an external server, or
// nothing configured. None of those is this check's to judge.
return Check{}, false
}
checked := false
for _, t := range targets {
if len(missing) == 0 {
return Check{Name: "server_database", Status: StatusOK}, true
}
named := make([]string, 0, len(missing))
for _, t := range missing {
named = append(named, fmt.Sprintf("%q on %s", t.Database, t.Service))
}
return Check{Name: "server_database", Status: StatusFail, Fix: FixCreateDatabase,
Detail: fmt.Sprintf("%s %s %s not exist. Create %s, then run migrations.",
plural(len(missing), "Database", "Databases"), strings.Join(named, ", "),
plural(len(missing), "does", "do"), plural(len(missing), "it", "them"))}, true
}

// MissingDatabases returns the lerd-managed databases a project points at that
// their engine does not hold. Exported so the fix works the set out again
// rather than trusting the client, the same way the service fixes do.
func MissingDatabases(path string) []config.DBTarget {
missing, _ := missingDatabases(path)
return missing
}

// missingDatabases pairs the set with whether any engine could be asked at all:
// an unreachable one leaves the site unjudged rather than reported as missing a
// schema that may well exist.
func missingDatabases(path string) (missing []config.DBTarget, checked bool) {
for _, t := range config.DBTargetsFor(path) {
names, err := listDatabases(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.
continue
}
checked = true
Expand All @@ -64,13 +91,8 @@ func checkServerDatabase(path string, fw *config.Framework) (Check, bool) {
}
}
if !found {
return Check{Name: "server_database", Status: StatusFail, Fix: migrateFix(fw),
Detail: fmt.Sprintf("Database %q does not exist on %s — create it with lerd db:create %s, then run migrations.", t.Database, t.Service, t.Database)}, true
missing = append(missing, t)
}
}
if !checked {
// Nothing could be asked, so there is nothing to report either way.
return Check{}, false
}
return Check{Name: "server_database", Status: StatusOK}, true
return missing, checked
}
7 changes: 3 additions & 4 deletions internal/sitedoctor/server_database_declared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,21 @@ func TestCheckServerDatabase_readsAPHPSettingsFile(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "web/sites/default/settings.php"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
fw, ok := config.GetFrameworkForDir("drupalish", dir)
if !ok {
if _, ok := config.GetFrameworkForDir("drupalish", dir); !ok {
t.Fatal("the test definition did not resolve")
}

restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"other"}, nil })
defer restore()
c, produced := checkServerDatabase(dir, fw)
c, produced := checkServerDatabase(dir)
if !produced || c.Status != StatusFail {
t.Fatalf("check = %+v (produced=%v), want a failure for the missing schema", c, produced)
}

restore()
restore2 := stubDatabaseLister(func(string) ([]string, error) { return []string{"shop"}, nil })
defer restore2()
if c, _ := checkServerDatabase(dir, fw); c.Status != StatusOK {
if c, _ := checkServerDatabase(dir); c.Status != StatusOK {
t.Errorf("check = %+v, want ok once the database exists", c)
}
}
66 changes: 66 additions & 0 deletions internal/sitedoctor/server_database_fix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package sitedoctor

import (
"errors"
"strings"
"testing"
)

// A schema that does not exist is not something migrations can create: the
// migrate command fails against a database the engine does not have, so the
// finding used to name a remedy that could not work and sent the user to the
// CLI. The fix creates the database, and the migrate button comes back on the
// re-check that follows.
func TestCheckServerDatabase_offersToCreateTheDatabase(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
t.Setenv("XDG_DATA_HOME", tmp)

dir := t.TempDir()
writeEnv(t, dir, ".env", "DB_CONNECTION=mysql\nDB_HOST=lerd-mysql\nDB_DATABASE=shop\n")
restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"other"}, nil })
defer restore()

c, ok := checkServerDatabase(dir)
if !ok || c.Status != StatusFail {
t.Fatalf("check = %+v (ok=%v), want a failure for the missing schema", c, ok)
}
if c.Fix != FixCreateDatabase {
t.Errorf("fix = %q, want %q", c.Fix, FixCreateDatabase)
}
if strings.Contains(c.Detail, "lerd db:create") {
t.Errorf("detail = %q, should not send the user to the CLI now the fix creates it", c.Detail)
}
}

// The fix works the set out again rather than trusting the client, so it can
// only ever create what the check would have reported.
func TestMissingDatabases(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
t.Setenv("XDG_DATA_HOME", tmp)

dir := t.TempDir()
writeEnv(t, dir, ".env", "DB_CONNECTION=mysql\nDB_HOST=lerd-mysql\nDB_DATABASE=shop\n")

restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"other"}, nil })
missing := MissingDatabases(dir)
restore()
if len(missing) != 1 || missing[0].Database != "shop" || missing[0].Service != "mysql" {
t.Fatalf("missing = %+v, want shop on mysql", missing)
}

restore = stubDatabaseLister(func(string) ([]string, error) { return []string{"shop"}, nil })
if got := MissingDatabases(dir); len(got) != 0 {
t.Errorf("missing = %+v, want none once the database exists", got)
}
restore()

// An engine that cannot be reached holds no missing database as far as the
// fix is concerned, or pressing it would create a schema that already exists.
restore = stubDatabaseLister(func(string) ([]string, error) { return nil, errors.New("engine down") })
defer restore()
if got := MissingDatabases(dir); len(got) != 0 {
t.Errorf("missing = %+v, want none while the engine is unreachable", got)
}
}
6 changes: 5 additions & 1 deletion internal/sitedoctor/sitedoctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ const (
// into the env file its framework declares, which is what `lerd env` does
// and what resolves a service picked but not wired.
FixEnvSync = "env_sync"
// FixCreateDatabase creates the databases a site points at that its engine
// does not hold, a host action like the service fixes since a site cannot
// create its own schema from inside its container.
FixCreateDatabase = "database_create"
)

// DoctorFixCommands maps each universal fix key to the shell command run in the
Expand Down Expand Up @@ -206,7 +210,7 @@ func Run(ctx context.Context, path string, fw *config.Framework) Response {
}
// Whether the site's database exists is answered through the framework
// declaration, so it is asked of every format, not only dotenv.
if c, ok := checkServerDatabase(path, fw); ok {
if c, ok := checkServerDatabase(path); ok {
resp.add(c)
dbBroken = dbBroken || c.Status == StatusFail
}
Expand Down
11 changes: 5 additions & 6 deletions internal/sitedoctor/sitedoctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -823,15 +823,14 @@ func TestCheckServerDatabase(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", tmp)
t.Setenv("XDG_DATA_HOME", tmp)

migrateFW := &config.Framework{Commands: []config.FrameworkCommand{{Name: "migrate"}}}
env := "DB_CONNECTION=mysql\nDB_HOST=lerd-mysql\nDB_DATABASE=shop\n"

t.Run("missing schema fails with a migrate fix", func(t *testing.T) {
t.Run("missing schema fails with a create fix", func(t *testing.T) {
dir := t.TempDir()
writeEnv(t, dir, ".env", env)
restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"other", "lerd"}, nil })
defer restore()
c, ok := checkServerDatabase(dir, migrateFW)
c, ok := checkServerDatabase(dir)
if !ok || c.Status != StatusFail {
t.Fatalf("a missing schema should fail, got ok=%v %+v", ok, c)
}
Expand All @@ -842,7 +841,7 @@ func TestCheckServerDatabase(t *testing.T) {
writeEnv(t, dir, ".env", env)
restore := stubDatabaseLister(func(string) ([]string, error) { return []string{"shop"}, nil })
defer restore()
c, ok := checkServerDatabase(dir, migrateFW)
c, ok := checkServerDatabase(dir)
if !ok || c.Status != StatusOK {
t.Fatalf("an existing schema should pass, got ok=%v %+v", ok, c)
}
Expand All @@ -853,15 +852,15 @@ func TestCheckServerDatabase(t *testing.T) {
writeEnv(t, dir, ".env", env)
restore := stubDatabaseLister(func(string) ([]string, error) { return nil, errors.New("engine down") })
defer restore()
if _, ok := checkServerDatabase(dir, migrateFW); ok {
if _, ok := checkServerDatabase(dir); ok {
t.Fatal("an engine that could not be queried should produce no check")
}
})

t.Run("sqlite is left to the sqlite check", func(t *testing.T) {
dir := t.TempDir()
writeEnv(t, dir, ".env", "DB_CONNECTION=sqlite\n")
if _, ok := checkServerDatabase(dir, migrateFW); ok {
if _, ok := checkServerDatabase(dir); ok {
t.Fatal("sqlite should be skipped")
}
})
Expand Down
35 changes: 35 additions & 0 deletions internal/ui/site_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package ui

import (
"encoding/json"
"fmt"
"net/http"
"strings"

"github.com/geodro/lerd/internal/config"
"github.com/geodro/lerd/internal/serviceops"
Expand Down Expand Up @@ -71,6 +73,13 @@ func handleDoctorFixRun(w http.ResponseWriter, r *http.Request, site *config.Sit
handleDoctorServiceFix(w, r, site, key)
return
}
// Creating a schema runs in the engine's container, not the site's, so it is
// a host action as well: the site the finding belongs to could not create it
// from the inside even with a shell.
if key == sitedoctor.FixCreateDatabase {
handleDoctorDatabaseFix(w, r, site)
return
}
shell, ok := sitedoctor.DoctorFixCommands[key]
if !ok {
writeJSON(w, map[string]any{"error": "unknown doctor fix: " + key})
Expand Down Expand Up @@ -161,6 +170,32 @@ func handleDoctorServiceFix(w http.ResponseWriter, r *http.Request, site *config
send("done", string(body))
}

// handleDoctorDatabaseFix creates the databases the site points at that their
// engine does not hold. Like the service fix it resolves the set again rather
// than trusting the client, so it can only ever create what the check reported,
// and it stops at the first failure instead of reporting a half-done run as done.
func handleDoctorDatabaseFix(w http.ResponseWriter, r *http.Request, site *config.Site) {
path, ok := resolveDoctorPath(w, site, r.URL.Query().Get("branch"))
if !ok {
return
}
missing := sitedoctor.MissingDatabases(path)
if len(missing) == 0 {
streamHostAction(w, "nothing to create: every database this site points at exists, or its engine could not be reached", nil)
return
}
var created []string
var failed error
for _, t := range missing {
if _, err := serviceops.CreateDatabase(t.Service, t.Database); err != nil {
failed = fmt.Errorf("creating %s on %s: %w", t.Database, t.Service, err)
break
}
created = append(created, t.Database+" on "+t.Service)
}
streamHostAction(w, "created "+strings.Join(created, ", "), failed)
}

// installPhaseLine renders one install phase as a line of output, skipping the
// pull's own progress chatter, which arrives many times a second and says
// nothing a doctor fix log needs.
Expand Down
Loading
Loading