From f7ad2609d8cbbd3f4b4d6345229a821d2124129a Mon Sep 17 00:00:00 2001 From: Juan Millan Date: Fri, 17 Jul 2026 12:28:03 +0200 Subject: [PATCH] add support lerdstead.yml multisite definition --- cmd/lerd/main.go | 1 + docs/reference/commands.md | 1 + docs/usage/lerdstead.md | 51 ++++ internal/cli/apply.go | 376 ++++++++++++++++++++++++++++++ internal/cli/apply_test.go | 150 ++++++++++++ internal/config/lerdstead.go | 81 +++++++ internal/config/lerdstead_test.go | 136 +++++++++++ internal/config/site.go | 7 + mkdocs.yml | 1 + 9 files changed, 804 insertions(+) create mode 100644 docs/usage/lerdstead.md create mode 100644 internal/cli/apply.go create mode 100644 internal/cli/apply_test.go create mode 100644 internal/config/lerdstead.go create mode 100644 internal/config/lerdstead_test.go diff --git a/cmd/lerd/main.go b/cmd/lerd/main.go index e42ff3df..d2da7945 100644 --- a/cmd/lerd/main.go +++ b/cmd/lerd/main.go @@ -106,6 +106,7 @@ func main() { root.AddCommand(cli.NewInitCmd()) root.AddCommand(cli.NewLinkCmd()) root.AddCommand(cli.NewUnlinkCmd()) + root.AddCommand(cli.NewApplyCmd()) root.AddCommand(cli.NewRestartCmd()) root.AddCommand(cli.NewRebuildCmd()) root.AddCommand(cli.NewUnparkCmd()) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 9889bf6f..67dc3e46 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -66,6 +66,7 @@ Setup steps include common tasks (composer install, npm install, lerd env) plus | `lerd link [name]` | Register the current directory as a site. On a fresh project with no `.lerd.yaml`, an interactive terminal routes through the `lerd init` wizard first (PHP version, HTTPS, services) before linking; prompts to import data when `laravel/sail` is detected in `composer.json`. **Non-PHP projects** (Node.js, Python, Go, etc.) must have `Containerfile.lerd` and `.lerd.yaml` with `container: {port: N}` already written before calling this, see [Custom Containers](../usage/custom-containers.md) | | `lerd link [name] --domain foo.test` | Register with a custom domain | | `lerd unlink [name]` | Stop serving the site | +| `lerd apply [file]` | Reconcile sites and services against a declarative `lerdstead.yml` (default `~/.config/lerd/lerdstead.yml`); `--yes` also unlinks sites removed from the file without asking. See [Declarative Sites](../usage/lerdstead.md) | | `lerd sites` | Table view of all registered sites | | `lerd open [name]` | Open the site in the default browser | | `lerd share [name]` | Expose the site publicly via ngrok, cloudflared, or Expose (auto-detected) | diff --git a/docs/usage/lerdstead.md b/docs/usage/lerdstead.md new file mode 100644 index 00000000..8e5701a6 --- /dev/null +++ b/docs/usage/lerdstead.md @@ -0,0 +1,51 @@ +# Declarative Sites (lerdstead.yml) + +If you're coming from Laravel Homestead, you're used to declaring your whole machine in one file and re-provisioning to converge on it. `lerd apply` brings that workflow to lerd: a `lerdstead.yml` file lists the projects this machine serves, and applying it links what's missing, updates what drifted, and unlinks what you removed from the file. + +## Commands + +| Command | Description | +|---|---| +| `lerd apply` | Reconcile sites and services against `~/.config/lerd/lerdstead.yml` | +| `lerd apply ` | Reconcile against an explicit file (handy for dotfiles repos) | +| `lerd apply --yes` | Also unlink sites removed from the file without asking | + +## The file + +The default location is `~/.config/lerd/lerdstead.yml`, next to `config.yaml`. Every key except `path` is optional: + +```yaml +sites: + - path: ~/code/blog + domains: [blog, admin.blog] # without the TLD, like .lerd.yaml + php_version: "8.3" + secured: true + services: [mysql, redis] + + - path: ~/code/shop # everything auto-detected, like lerd link + +services: [mysql@8.4] # global presets to keep installed and running +park: [~/code/clients] # directories to keep parked +``` + +## What applying does + +Running `lerd apply` walks the file top to bottom: + +1. **Park** each directory under `park:` (same as `lerd park`). +2. **Ensure global services** under `services:` are installed and running. Use `name@version` to pin a preset version. +3. **Converge each site.** A path lerd doesn't know yet goes through the normal link pipeline, so the project's `.lerd.yaml`, framework detection, and required services all apply exactly as they would for `lerd link`. A site that already exists is updated in place: domains, PHP version, HTTPS state, and services are each brought to the declared value, and anything already matching is left untouched. +4. **Prune.** A site that was provisioned from the file and has since been removed from it is unlinked, after a confirmation prompt (`--yes` skips it; a non-interactive run without `--yes` only reports what it would remove). Sites you linked manually are never pruned. + +Applying is idempotent: running it twice in a row does nothing the second time. + +## How it interacts with .lerd.yaml + +The two files answer different questions. `.lerd.yaml` is committed to the project's repo and describes the project: its framework, workers, env wiring, containers. `lerdstead.yml` is machine config and describes *which* projects this machine serves. On overlap (domains, PHP version, HTTPS, services) the lerdstead entry wins, the same way an explicit `lerd link` argument would, but nothing from `lerdstead.yml` is ever written into the project's `.lerd.yaml`. + +A few details worth knowing: + +- `secured` is tri-state: `true` enables HTTPS, `false` disables it, and leaving the key out keeps whatever the site currently has, so a manual `lerd secure` isn't undone by a file that never mentions it. +- A declared domain that already belongs to another site is skipped with a warning, never stolen. +- `php_version` is clamped to the framework's supported range, like every other PHP switch. +- Once a path appears in the file, that site counts as file-managed and becomes prunable when you remove it later. This mirrors Homestead: the file is the source of truth for what it lists. diff --git a/internal/cli/apply.go b/internal/cli/apply.go new file mode 100644 index 00000000..89536057 --- /dev/null +++ b/internal/cli/apply.go @@ -0,0 +1,376 @@ +package cli + +import ( + "fmt" + "os" + "slices" + "strings" + + "github.com/geodro/lerd/internal/certs" + "github.com/geodro/lerd/internal/config" + "github.com/geodro/lerd/internal/feedback" + "github.com/geodro/lerd/internal/grouping" + "github.com/geodro/lerd/internal/nginx" + "github.com/geodro/lerd/internal/podman" + "github.com/geodro/lerd/internal/siteops" + "github.com/spf13/cobra" +) + +// NewApplyCmd returns the apply command: reconcile the machine's sites and +// services against a declarative lerdstead.yml. +func NewApplyCmd() *cobra.Command { + var assumeYes bool + cmd := &cobra.Command{ + Use: "apply [file]", + Short: "Reconcile sites and services from lerdstead.yml", + Long: "Link, update, and prune sites declared in a lerdstead.yml file (default " + + config.LerdsteadFile() + "). Sites in the file are linked or converged to the " + + "declared domains, PHP version, HTTPS state, and services; sites the file " + + "previously provisioned and no longer lists are unlinked. Manually linked " + + "sites are never pruned.", + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + file := config.LerdsteadFile() + if len(args) > 0 { + file = args[0] + } + return runApply(file, assumeYes) + }, + } + cmd.Flags().BoolVar(&assumeYes, "yes", false, "Unlink sites removed from the file without the confirmation prompt") + return cmd +} + +func runApply(file string, assumeYes bool) error { + ls, err := config.LoadLerdstead(file) + if os.IsNotExist(err) { + return fmt.Errorf("%s not found — create it with a sites: list, or pass a path to lerd apply", file) + } + if err != nil { + return err + } + cfg, err := config.LoadGlobal() + if err != nil { + return err + } + + feedback.Begin() + feedback.Line("applying " + feedback.Val(file)) + + for _, dir := range ls.Park { + if err := runPark(nil, []string{dir}); err != nil { + feedback.Warn("parking %s: %v", dir, err) + } + } + + for _, ref := range ls.Services { + ensureSteadService(ref) + } + + // Suppress the interactive follow-ups a standalone `lerd link` offers; + // apply runs the pipeline once per declared site and must not stop to chat. + prevSetup, prevImport := linkSkipSetupPrompt, linkSkipDataImport + linkSkipSetupPrompt, linkSkipDataImport = true, true + defer func() { linkSkipSetupPrompt, linkSkipDataImport = prevSetup, prevImport }() + + failed := 0 + for _, entry := range ls.Sites { + if err := applySteadSite(entry, cfg); err != nil { + feedback.Warn("%s: %v", entry.Path, err) + failed++ + } + } + + if err := pruneSteadSites(ls, assumeYes); err != nil { + return err + } + + if failed > 0 { + return fmt.Errorf("%d of %d site(s) could not be applied", failed, len(ls.Sites)) + } + feedback.Done(fmt.Sprintf("%d site(s) in sync with %s", len(ls.Sites), file)) + return nil +} + +// applySteadSite converges one declared site: link it if unknown, then bring +// domains, PHP version, HTTPS, and services to the declared state. Every step +// is a no-op when the site already matches, so re-applying is free. +func applySteadSite(entry config.LerdsteadSite, cfg *config.GlobalConfig) error { + path := config.CanonicalPath(entry.Path) + if fi, err := os.Stat(path); err != nil || !fi.IsDir() { + return fmt.Errorf("directory does not exist") + } + + site, _ := config.FindSiteByPath(path) + if site == nil { + if err := steadLinkSite(path, entry); err != nil { + return err + } + site, _ = config.FindSiteByPath(path) + if site == nil { + // runLink declined without an error: a worktree of another site, or + // a directory it could not register. Nothing to converge. + return fmt.Errorf("not linkable as a standalone site") + } + } + + if !site.Lerdstead { + site.Lerdstead = true + if err := config.AddSite(*site); err != nil { + return err + } + } + + if err := steadApplyDomains(site, entry, cfg); err != nil { + return err + } + if err := steadApplyPHP(site, entry); err != nil { + return err + } + if err := steadApplySecured(site, entry, cfg); err != nil { + return err + } + return steadApplyServices(site.Path, entry.Services) +} + +// steadLinkSite runs the standard link pipeline for a declared site. runLink +// works off the working directory (it is what `lerd link` and the init wizard +// share), so apply visits the project the same way a user would. +func steadLinkSite(path string, entry config.LerdsteadSite) error { + prev, err := os.Getwd() + if err != nil { + return err + } + if err := os.Chdir(path); err != nil { + return err + } + defer os.Chdir(prev) //nolint:errcheck + + var args []string + if len(entry.Domains) > 0 { + args = []string{entry.Domains[0]} + } + return runLink(args) +} + +// steadWantDomains maps declared TLD-less domains to full lowercase domains. +func steadWantDomains(domains []string, tld string) []string { + if len(domains) == 0 { + return nil + } + out := make([]string, len(domains)) + for i, d := range domains { + out[i] = strings.ToLower(d) + "." + tld + } + return out +} + +// steadApplyDomains converges the site's domain list to the declared one, +// following the same sequence as `lerd domain add`: registry, .lerd.yaml, +// vhost, cert SANs, container hosts, nginx reload, env, and group cascade. +// A domain owned by another site is skipped with a warning, never stolen. +func steadApplyDomains(site *config.Site, entry config.LerdsteadSite, cfg *config.GlobalConfig) error { + want := steadWantDomains(entry.Domains, cfg.DNS.TLD) + if want == nil { + return nil + } + kept := want[:0] + for _, d := range want { + if other, err := config.IsDomainUsed(d); err == nil && other != nil && other.Name != site.Name { + feedback.Warn("domain %s already belongs to site %q; skipping it", d, other.Name) + continue + } + if isReservedDomain(d) { + feedback.Warn("domain %s is reserved for internal Lerd use; skipping it", d) + continue + } + kept = append(kept, d) + } + if len(kept) == 0 || slices.Equal(kept, site.Domains) { + return nil + } + + oldPrimary := site.PrimaryDomain() + site.Domains = slices.Clone(kept) + if err := config.AddSite(*site); err != nil { + return fmt.Errorf("updating site registry: %w", err) + } + _ = config.SyncProjectDomains(site.Path, site.Domains, cfg.DNS.TLD) + if err := siteops.RegenerateSiteVhost(site, oldPrimary); err != nil { + return err + } + if site.Secured { + if err := certs.ReissueCertForWorktree(*site); err != nil { + feedback.Warn("reissuing certificate: %v", err) + } + } + if err := podman.WriteContainerHosts(); err != nil { + feedback.Warn("updating container hosts file: %v", err) + } + nginx.ReloadOrWarn("") + if err := siteops.SyncEnvIfPrimaryChanged(site, oldPrimary); err != nil { + feedback.Warn("syncing .env to new primary domain: %v", err) + } + if site.IsGroupMain() { + if err := grouping.CascadeMainDomainChange(site); err != nil { + feedback.Warn("cascading group domain change: %v", err) + } + } + feedback.Line(site.Name + " domains → " + feedback.Val(strings.Join(site.Domains, ", "))) + return nil +} + +// steadApplyPHP converges the site to the declared PHP version through the +// shared SetSitePHPVersion funnel, building the FPM image afterwards when the +// version has never been built (the funnel itself never builds). +func steadApplyPHP(site *config.Site, entry config.LerdsteadSite) error { + if entry.PHPVersion == "" || entry.PHPVersion == site.PHPVersion { + return nil + } + if site.IsCustomContainer() || site.IsHostProxy() { + feedback.Warn("%s: php_version does not apply to a %s site", site.Name, linkRuntimeKind(site)) + return nil + } + step := feedback.Start("switching " + site.Name + " to PHP " + entry.PHPVersion) + res, err := siteops.SetSitePHPVersion(site, entry.PHPVersion, siteops.PHPVersionOpts{}) + if err != nil { + step.Fail(err) + return err + } + if res.NotInstalled || res.Stale { + if err := ensureFPMQuadlet(res.Version); err != nil { + step.Fail(err) + return err + } + } + label := feedback.Val(res.Version) + if res.Clamped { + label += " (clamped from " + res.Requested + ")" + } + step.OK(label) + return nil +} + +// linkRuntimeKind names the serving mode for warnings about settings that +// don't apply to it. +func linkRuntimeKind(site *config.Site) string { + if site.IsCustomContainer() { + return "custom-container" + } + return "host-proxy" +} + +// steadApplySecured converges HTTPS to the declared state. An absent secured +// key (nil) leaves the site alone, so a manual `lerd secure` is not undone by +// a file that never mentions it. +func steadApplySecured(site *config.Site, entry config.LerdsteadSite, cfg *config.GlobalConfig) error { + if entry.Secured == nil || *entry.Secured == site.Secured { + return nil + } + if *entry.Secured && !cfg.DNSManaged() { + feedback.Warn("%s: cannot enable HTTPS while lerd DNS is disabled", site.Name) + return nil + } + return toggleSecureCmd([]string{site.Name}, *entry.Secured) +} + +// steadApplyServices installs and starts the declared service presets through +// the same path a .lerd.yaml services list takes on link. The list is built in +// memory: lerdstead.yml is machine config and must not rewrite the project's +// committed .lerd.yaml. +func steadApplyServices(path string, refs []string) error { + if len(refs) == 0 { + return nil + } + return linkApplyServices(path, &config.ProjectConfig{Services: steadProjectServices(refs)}) +} + +// steadProjectServices maps "name" / "name@version" refs to ProjectService +// entries, mirroring how ensureRequiredServices shapes them. +func steadProjectServices(refs []string) []config.ProjectService { + out := make([]config.ProjectService, 0, len(refs)) + for _, ref := range refs { + name, version := parseServiceRef(ref) + svc := config.ProjectService{Name: name, PresetVersion: version} + if !config.IsDefaultPreset(name) { + svc.Preset = name + } + out = append(out, svc) + } + return out +} + +// parseServiceRef splits a "name@version" service reference; version is empty +// when no pin is given. +func parseServiceRef(ref string) (name, version string) { + name, version, _ = strings.Cut(ref, "@") + return name, version +} + +// ensureSteadService installs a global service preset if missing and makes +// sure it is running. +func ensureSteadService(ref string) { + name, version := parseServiceRef(ref) + if _, err := config.LoadCustomService(name); err != nil { + fmt.Printf(" Installing preset %s%s\n", name, presetVersionSuffix(version)) + if _, err := InstallPresetByName(name, version); err != nil { + feedback.Warn("installing service %s: %v", name, err) + return + } + } + if err := ensureServiceRunning(name); err != nil { + feedback.Warn("service %s: %v", name, err) + } +} + +// steadStaleSites returns the sites apply may prune: provisioned from +// lerdstead.yml (the Lerdstead flag), still active, and no longer declared. +func steadStaleSites(sites []config.Site, declared map[string]bool) []config.Site { + var out []config.Site + for _, s := range sites { + if s.Lerdstead && !s.Ignored && !declared[config.CanonicalPath(s.Path)] { + out = append(out, s) + } + } + return out +} + +// pruneSteadSites unlinks sites the file previously provisioned and no longer +// lists. It confirms first; --yes skips the prompt, and a non-interactive run +// without --yes only reports what it would remove. +func pruneSteadSites(ls *config.Lerdstead, assumeYes bool) error { + reg, err := config.LoadSites() + if err != nil { + return err + } + declared := make(map[string]bool, len(ls.Sites)) + for _, entry := range ls.Sites { + declared[config.CanonicalPath(entry.Path)] = true + } + + stale := steadStaleSites(reg.Sites, declared) + if len(stale) == 0 { + return nil + } + names := make([]string, len(stale)) + for i, s := range stale { + names[i] = s.Name + } + + if !assumeYes { + if !isInteractive() { + feedback.Note("no longer in the file (re-run with --yes to unlink): " + strings.Join(names, ", ")) + return nil + } + q := fmt.Sprintf("Unlink %d site(s) no longer in the file (%s)?", len(stale), strings.Join(names, ", ")) + if !feedback.Confirm(q, false) { + return nil + } + } + for _, s := range stale { + if err := UnlinkSite(s.Name); err != nil { + feedback.Warn("unlinking %s: %v", s.Name, err) + } + } + return nil +} diff --git a/internal/cli/apply_test.go b/internal/cli/apply_test.go new file mode 100644 index 00000000..0de1b741 --- /dev/null +++ b/internal/cli/apply_test.go @@ -0,0 +1,150 @@ +package cli + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/geodro/lerd/internal/config" +) + +func TestParseServiceRef(t *testing.T) { + cases := []struct { + ref string + name string + version string + }{ + {"mysql", "mysql", ""}, + {"mysql@8.4", "mysql", "8.4"}, + {"redis@7", "redis", "7"}, + } + for _, c := range cases { + name, version := parseServiceRef(c.ref) + if name != c.name || version != c.version { + t.Errorf("parseServiceRef(%q) = (%q, %q), want (%q, %q)", c.ref, name, version, c.name, c.version) + } + } +} + +func TestSteadWantDomains(t *testing.T) { + got := steadWantDomains([]string{"Blog", "admin.blog"}, "test") + want := []string{"blog.test", "admin.blog.test"} + if !reflect.DeepEqual(got, want) { + t.Errorf("steadWantDomains = %v, want %v", got, want) + } + if steadWantDomains(nil, "test") != nil { + t.Error("steadWantDomains(nil) should be nil") + } +} + +func TestSteadStaleSites(t *testing.T) { + sites := []config.Site{ + {Name: "kept", Path: "/srv/kept", Lerdstead: true}, + {Name: "stale", Path: "/srv/stale", Lerdstead: true}, + {Name: "manual", Path: "/srv/manual"}, + {Name: "ignored", Path: "/srv/ignored", Lerdstead: true, Ignored: true}, + } + declared := map[string]bool{"/srv/kept": true} + + got := steadStaleSites(sites, declared) + if len(got) != 1 || got[0].Name != "stale" { + t.Errorf("steadStaleSites = %v, want only \"stale\"", got) + } +} + +func TestApplySteadSiteMarksProvenance(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + dir := filepath.Join(t.TempDir(), "blog") + os.MkdirAll(dir, 0755) + if err := config.AddSite(config.Site{Name: "blog", Domains: []string{"blog.test"}, Path: dir, PHPVersion: "8.4"}); err != nil { + t.Fatal(err) + } + + cfg := &config.GlobalConfig{} + cfg.DNS.TLD = "test" + // A bare entry (no overrides) must only claim the site for the file, + // leaving domains, PHP, and HTTPS untouched. + if err := applySteadSite(config.LerdsteadSite{Path: dir}, cfg); err != nil { + t.Fatalf("applySteadSite: %v", err) + } + + site, err := config.FindSite("blog") + if err != nil { + t.Fatal(err) + } + if !site.Lerdstead { + t.Error("site was not marked as lerdstead-managed") + } + if site.PHPVersion != "8.4" || !reflect.DeepEqual(site.Domains, []string{"blog.test"}) || site.Secured { + t.Errorf("bare entry changed the site: %+v", site) + } +} + +func TestSteadApplyDomainsSkipsConflictsWithoutChange(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + dir := t.TempDir() + config.AddSite(config.Site{Name: "mysite", Domains: []string{"mysite.test"}, Path: dir}) + config.AddSite(config.Site{Name: "other", Domains: []string{"other.test"}, Path: t.TempDir()}) + + cfg := &config.GlobalConfig{} + cfg.DNS.TLD = "test" + site, _ := config.FindSite("mysite") + + // "other" is owned elsewhere; what survives equals the current list, so + // the converge must be a clean no-op (no vhost work, no registry write). + entry := config.LerdsteadSite{Path: dir, Domains: []string{"mysite", "other"}} + if err := steadApplyDomains(site, entry, cfg); err != nil { + t.Fatalf("steadApplyDomains: %v", err) + } + got, _ := config.FindSite("mysite") + if !reflect.DeepEqual(got.Domains, []string{"mysite.test"}) { + t.Errorf("domains changed: %v", got.Domains) + } + other, _ := config.FindSite("other") + if !reflect.DeepEqual(other.Domains, []string{"other.test"}) { + t.Errorf("conflicting site was touched: %v", other.Domains) + } +} + +func TestSteadApplySecuredRespectsDNSAndAbsence(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + dir := t.TempDir() + config.AddSite(config.Site{Name: "app", Domains: []string{"app.test"}, Path: dir}) + site, _ := config.FindSite("app") + + cfg := &config.GlobalConfig{} + cfg.DNS.TLD = "test" // DNS.Enabled false → unmanaged + + // Absent key: leave the site alone. + if err := steadApplySecured(site, config.LerdsteadSite{Path: dir}, cfg); err != nil { + t.Fatalf("nil secured: %v", err) + } + // secured: true with lerd DNS disabled: warn and skip, not an error. + yes := true + if err := steadApplySecured(site, config.LerdsteadSite{Path: dir, Secured: &yes}, cfg); err != nil { + t.Fatalf("secured with DNS off: %v", err) + } + if got, _ := config.FindSite("app"); got.Secured { + t.Error("site must stay unsecured when lerd DNS is disabled") + } +} + +func TestSteadProjectServices(t *testing.T) { + got := steadProjectServices([]string{"mysql", "rabbitmq@4"}) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + // mysql is a default preset: referenced by bare name, no Preset field. + if got[0].Name != "mysql" || got[0].Preset != "" { + t.Errorf("default preset entry = %+v", got[0]) + } + // rabbitmq is an add-on preset: Preset mirrors the name, version pinned. + if got[1].Name != "rabbitmq" || got[1].Preset != "rabbitmq" || got[1].PresetVersion != "4" { + t.Errorf("add-on preset entry = %+v", got[1]) + } +} diff --git a/internal/config/lerdstead.go b/internal/config/lerdstead.go new file mode 100644 index 00000000..68854275 --- /dev/null +++ b/internal/config/lerdstead.go @@ -0,0 +1,81 @@ +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// LerdsteadSite is one declared project in lerdstead.yml. Domains are listed +// without the TLD, matching .lerd.yaml. Secured is a *bool so an absent key +// leaves the site's current HTTPS state alone instead of forcing it off. +type LerdsteadSite struct { + Path string `yaml:"path"` + Domains []string `yaml:"domains,omitempty"` + PHPVersion string `yaml:"php_version,omitempty"` + Secured *bool `yaml:"secured,omitempty"` + Services []string `yaml:"services,omitempty"` +} + +// Lerdstead is the machine-level declarative site list `lerd apply` reconciles +// against: which projects this machine serves, plus service presets to ensure +// installed ("name" or "name@version") and directories to park. +type Lerdstead struct { + Sites []LerdsteadSite `yaml:"sites"` + Services []string `yaml:"services,omitempty"` + Park []string `yaml:"park,omitempty"` +} + +// LerdsteadFile returns the default lerdstead.yml path next to config.yaml. +func LerdsteadFile() string { + return filepath.Join(ConfigDir(), "lerdstead.yml") +} + +// LoadLerdstead reads and validates a lerdstead.yml. Decoding is strict so a +// typoed key fails loudly instead of silently not applying. Site and park +// paths come back tilde-expanded and absolute-cleaned. +func LoadLerdstead(path string) (*Lerdstead, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var ls Lerdstead + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&ls); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + seen := make(map[string]bool, len(ls.Sites)) + for i := range ls.Sites { + s := &ls.Sites[i] + if s.Path == "" { + return nil, fmt.Errorf("%s: sites[%d] has no path", path, i) + } + s.Path = expandHomePath(s.Path) + if seen[s.Path] { + return nil, fmt.Errorf("%s: path %s is declared twice", path, s.Path) + } + seen[s.Path] = true + } + for i := range ls.Park { + ls.Park[i] = expandHomePath(ls.Park[i]) + } + return &ls, nil +} + +// expandHomePath resolves a leading ~ or ~/ against the user's home directory +// and cleans the result, so declared paths compare stably against the registry. +func expandHomePath(p string) string { + if p == "~" || strings.HasPrefix(p, "~/") { + if home, err := os.UserHomeDir(); err == nil { + p = filepath.Join(home, strings.TrimPrefix(p[1:], "/")) + } + } + return filepath.Clean(p) +} diff --git a/internal/config/lerdstead_test.go b/internal/config/lerdstead_test.go new file mode 100644 index 00000000..807f89bd --- /dev/null +++ b/internal/config/lerdstead_test.go @@ -0,0 +1,136 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeLerdstead(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "lerdstead.yml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadLerdstead(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + path := writeLerdstead(t, ` +sites: + - path: ~/code/blog + domains: [blog, admin.blog] + php_version: "8.3" + secured: true + services: [mysql, redis] + - path: /srv/shop + secured: false + - path: ~/code/plain + +services: [mysql@8.4] +park: [~/clients] +`) + + ls, err := LoadLerdstead(path) + if err != nil { + t.Fatalf("LoadLerdstead: %v", err) + } + if len(ls.Sites) != 3 { + t.Fatalf("sites = %d, want 3", len(ls.Sites)) + } + + blog := ls.Sites[0] + if blog.Path != filepath.Join(home, "code", "blog") { + t.Errorf("tilde not expanded: %q", blog.Path) + } + if len(blog.Domains) != 2 || blog.Domains[0] != "blog" { + t.Errorf("domains = %v", blog.Domains) + } + if blog.PHPVersion != "8.3" { + t.Errorf("php_version = %q", blog.PHPVersion) + } + if blog.Secured == nil || !*blog.Secured { + t.Errorf("secured = %v, want true", blog.Secured) + } + if len(blog.Services) != 2 { + t.Errorf("services = %v", blog.Services) + } + + if shop := ls.Sites[1]; shop.Secured == nil || *shop.Secured { + t.Errorf("explicit secured: false must parse as *false, got %v", shop.Secured) + } + if plain := ls.Sites[2]; plain.Secured != nil { + t.Errorf("absent secured must stay nil, got %v", *plain.Secured) + } + + if len(ls.Services) != 1 || ls.Services[0] != "mysql@8.4" { + t.Errorf("global services = %v", ls.Services) + } + if len(ls.Park) != 1 || ls.Park[0] != filepath.Join(home, "clients") { + t.Errorf("park = %v", ls.Park) + } +} + +func TestLoadLerdsteadRejectsMissingPath(t *testing.T) { + path := writeLerdstead(t, "sites:\n - domains: [blog]\n") + if _, err := LoadLerdstead(path); err == nil || !strings.Contains(err.Error(), "path") { + t.Fatalf("want missing-path error, got %v", err) + } +} + +func TestLoadLerdsteadRejectsDuplicatePath(t *testing.T) { + path := writeLerdstead(t, ` +sites: + - path: /srv/app + - path: /srv/app +`) + if _, err := LoadLerdstead(path); err == nil || !strings.Contains(err.Error(), "twice") { + t.Fatalf("want duplicate-path error, got %v", err) + } +} + +func TestLoadLerdsteadRejectsUnknownField(t *testing.T) { + path := writeLerdstead(t, ` +sites: + - path: /srv/app + php_versoin: "8.3" +`) + if _, err := LoadLerdstead(path); err == nil { + t.Fatal("want error on unknown field (typo), got nil") + } +} + +func TestLoadLerdsteadMissingFile(t *testing.T) { + _, err := LoadLerdstead(filepath.Join(t.TempDir(), "absent.yml")) + if !os.IsNotExist(err) { + t.Fatalf("want IsNotExist error, got %v", err) + } +} + +func TestSiteLerdsteadFlagRoundTrips(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + if err := AddSite(Site{Name: "blog", Domains: []string{"blog.test"}, Path: "/srv/blog", Lerdstead: true}); err != nil { + t.Fatalf("AddSite: %v", err) + } + site, err := FindSite("blog") + if err != nil { + t.Fatalf("FindSite: %v", err) + } + if !site.Lerdstead { + t.Error("Lerdstead flag was not persisted through sites.yaml") + } +} + +func TestLerdsteadFile(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + want := filepath.Join(tmp, "lerd", "lerdstead.yml") + if got := LerdsteadFile(); got != want { + t.Errorf("LerdsteadFile() = %q, want %q", got, want) + } +} diff --git a/internal/config/site.go b/internal/config/site.go index 024a88b1..5c2afcf4 100644 --- a/internal/config/site.go +++ b/internal/config/site.go @@ -71,6 +71,10 @@ type Site struct { // on the host for this site (project-origin custom host workers and commands). // Keyed by the exact string so a changed command re-prompts. ApprovedCommands []string `yaml:"approved_commands,omitempty"` + // Lerdstead marks a site provisioned from lerdstead.yml. `lerd apply` only + // prunes sites carrying this flag, so a manually linked site is never + // unlinked by a file edit. + Lerdstead bool `yaml:"lerdstead,omitempty"` // Group is the group key shared by a main site and its secondaries. It is // set to the main site's name. Empty when the site is not grouped. Group string `yaml:"group,omitempty"` @@ -202,6 +206,7 @@ type siteYAML struct { HostSSL bool `yaml:"host_ssl,omitempty"` HostCommand string `yaml:"host_command,omitempty"` ApprovedCommands []string `yaml:"approved_commands,omitempty"` + Lerdstead bool `yaml:"lerdstead,omitempty"` Group string `yaml:"group,omitempty"` GroupSubdomain string `yaml:"group_subdomain,omitempty"` GroupSharedDB bool `yaml:"group_shared_db,omitempty"` @@ -234,6 +239,7 @@ func (s Site) toYAML() siteYAML { HostSSL: s.HostSSL, HostCommand: s.HostCommand, ApprovedCommands: s.ApprovedCommands, + Lerdstead: s.Lerdstead, Group: s.Group, GroupSubdomain: s.GroupSubdomain, GroupSharedDB: s.GroupSharedDB, @@ -271,6 +277,7 @@ func (sy siteYAML) toSite() Site { HostSSL: sy.HostSSL, HostCommand: sy.HostCommand, ApprovedCommands: sy.ApprovedCommands, + Lerdstead: sy.Lerdstead, Group: sy.Group, GroupSubdomain: sy.GroupSubdomain, GroupSharedDB: sy.GroupSharedDB, diff --git a/mkdocs.yml b/mkdocs.yml index 6f9a7514..678b0e76 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,6 +85,7 @@ nav: - Quick Start: getting-started/quick-start.md - Usage: - Site Management: usage/sites.md + - Declarative Sites: usage/lerdstead.md - PHP: usage/php.md - Node: usage/node.md - Host-Proxy Sites: usage/host-proxy.md