diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fcb2cc8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,310 @@ +# AGENTS.md + +Guide for AI coding agents working in the `pv` codebase. + +## What is pv + +`pv` is a local development server manager powered by FrankenPHP (Caddy + embedded PHP). It manages FrankenPHP instances serving projects under `.test` domains with HTTPS, supporting multiple PHP versions simultaneously. Written in Go using Cobra for CLI. + +## Build, Test & Lint Commands + +```bash +# Build +go build -o pv . + +# Run all tests +go test ./... + +# Run tests for a single package +go test ./internal/registry/ +go test ./cmd/ + +# Run a single test or matching pattern +go test ./cmd/ -run TestLink +go test ./internal/phpenv/ -run TestResolveVersion + +# Verbose output +go test ./... -v + +# Test with coverage +go test ./... -cover + +# Format code (use goimports, not gofmt) +goimports -w . + +# Lint (if golangci-lint is available) +golangci-lint run +``` + +## Architecture Overview + +``` +main.go # Entry point → calls cmd.Execute() +cmd/ # Cobra commands (user-facing CLI) +internal/ + config/ # ~/.pv/ paths & settings + registry/ # Project registry (JSON) + phpenv/ # PHP version management + caddy/ # Caddyfile generation + server/ # Process management (FrankenPHP + DNS) + binaries/ # Binary downloads + detection/ # Project type detection + setup/ # Installation helpers +``` + +See `CLAUDE.md` for detailed architecture, directory layout, and multi-version architecture. + +## Code Style Guidelines + +### Imports + +Use standard Go import order (automatically handled by `goimports`): +```go +import ( + // 1. Standard library (alphabetical) + "encoding/json" + "fmt" + "os" + + // 2. External packages (alphabetical) + "github.com/spf13/cobra" + + // 3. Internal packages (alphabetical) + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/registry" +) +``` + +### Formatting + +- Use `goimports` (not `gofmt`) — it handles imports + formatting +- Tabs for indentation (Go standard) +- No trailing whitespace +- One declaration per line + +### Types + +**Struct definitions:** +```go +// JSON-serializable structs use tags +type Project struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + PHP string `json:"php,omitempty"` // omitempty for optional +} + +// Internal structs (no serialization) use simple form +type siteData struct { + Name string + Path string + RootPath string +} +``` + +**Always use pointer receivers for methods:** +```go +func (r *Registry) Add(p Project) error { ... } +func (s *Settings) Save() error { ... } +``` + +### Naming Conventions + +**Variables:** +- Short names in local scope: `reg`, `p`, `s`, `v`, `err` +- Full names for package-level/exported: `linkName`, `Settings`, `GlobalVersion` +- Single-letter or short receivers: `r` for Registry, `s` for Settings + +**Functions:** +- Action verbs: `Add`, `Remove`, `Save`, `Start`, `Stop`, `Install` +- Query verbs: `Find`, `List`, `IsInstalled`, `IsRunning` +- Get/Set: `GlobalVersion`, `SetGlobal` +- Generate: `GenerateSiteConfig`, `GenerateCaddyfile` +- Resolve: `ResolveVersion`, `resolveRoot` + +**Tests:** +```go +// Format: Test{FunctionName}_{Scenario} +func TestAdd_ToEmpty(t *testing.T) { ... } +func TestAdd_Duplicate(t *testing.T) { ... } +func TestRemove_NonExistent(t *testing.T) { ... } +``` + +**Constants:** +- UPPER_SNAKE_CASE for config: `DNSPort = 10053` +- camelCase for templates (unexported): `laravelTmpl`, `mainCaddyfile` + +### Error Handling + +**Always return errors as last value:** +```go +func Load() (*Registry, error) { ... } +func (r *Registry) Save() error { ... } +``` + +**Wrap errors with context using fmt.Errorf + %w:** +```go +if err := registry.Load(); err != nil { + return fmt.Errorf("cannot load registry: %w", err) +} +``` + +**Create new errors with fmt.Errorf (no %w):** +```go +if name == "" { + return fmt.Errorf("project name cannot be empty") +} +``` + +**Check errors immediately:** +```go +data, err := os.ReadFile(path) +if err != nil { + if os.IsNotExist(err) { + return &Registry{}, nil // Special case first + } + return nil, err // General error +} +``` + +**No naked returns — always explicit:** +```go +if err != nil { + return nil, err // Explicit nil, explicit error +} +return ®, nil // Explicit value, explicit nil +``` + +### Comments + +**Godoc style for exported functions:** +```go +// InstalledVersions returns all PHP versions that have been installed. +// It scans ~/.pv/php/ for directories containing a frankenphp binary. +func InstalledVersions() ([]string, error) { ... } +``` + +- First sentence is summary (appears in godoc) +- Explain parameters, return values, and special cases +- Full sentences with periods for godoc comments +- No period for short inline comments + +### Testing Patterns + +**CRITICAL: Always isolate tests with t.TempDir() + t.Setenv:** +```go +func TestSomething(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + // All ~/.pv/ operations now go to temp dir +} +``` + +**Helper functions must use t.Helper():** +```go +func scaffold(t *testing.T) string { + t.Helper() // Makes failures point to caller + home := t.TempDir() + t.Setenv("HOME", home) + return home +} +``` + +**Build fresh cobra commands per test:** +```go +func newLinkCmd() *cobra.Command { + var name string // Local variable + root := &cobra.Command{Use: "pv"} + link := &cobra.Command{ + Use: "link", + RunE: func(cmd *cobra.Command, args []string) error { + linkName = name // Sync to package var + return linkCmd.RunE(cmd, args) + }, + } + link.Flags().StringVar(&name, "name", "", "") + root.AddCommand(link) + return root +} +``` + +**Table-driven tests for multiple cases:** +```go +func TestPortForVersion(t *testing.T) { + tests := []struct { + version string + want int + }{ + {"8.3", 8830}, + {"8.4", 8840}, + } + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + got := PortForVersion(tt.version) + if got != tt.want { + t.Errorf("got %d, want %d", got, tt.want) + } + }) + } +} +``` + +**Standard assertions:** +```go +if err != nil { + t.Fatalf("Function() error = %v", err) // Fatal stops +} +if got != want { + t.Errorf("got %q, want %q", got, want) // Error continues +} +``` + +### File Operations + +**Always use filepath package:** +```go +path := filepath.Join(config.SitesDir(), name+".caddy") // NOT string concat +name := filepath.Base(absPath) +dir := filepath.Dir(destPath) +``` + +**Standard permissions:** +```go +os.WriteFile(path, data, 0644) // Regular files +os.MkdirAll(dir, 0755) // Directories +os.Chmod(path, 0755) // Executables +``` + +**Atomic file writes (temp + rename):** +```go +tmp, err := os.CreateTemp(dir, ".pv-download-*") +// ... write to tmp ... +if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return err +} +if err := os.Rename(tmp.Name(), destPath); err != nil { + os.Remove(tmp.Name()) + return err +} +``` + +## Key Principles + +1. **Test isolation via HOME redirection** — `t.Setenv("HOME", t.TempDir())` +2. **Fresh cobra commands for tests** — Avoid state leakage +3. **Error wrapping with context** — `fmt.Errorf("...: %w", err)` +4. **No interfaces** — All concrete types, no mocking +5. **Helper functions marked with t.Helper()** — Better error messages +6. **Atomic file operations** — temp file + rename +7. **Pointer receivers everywhere** — Consistency +8. **Standard library first** — Minimal external dependencies +9. **Explicit returns** — No naked returns +10. **Use goimports, not gofmt** — Handles imports + formatting + +## Testing Strategy + +- **Unit tests** (`go test ./...`): Run locally with filesystem isolation via `t.Setenv("HOME", t.TempDir())`. Use fake binaries (bash scripts) when needed. +- **E2E tests** (`.github/workflows/e2e.yml` + `scripts/e2e/`): Run on GitHub Actions for real binary execution, network calls, DNS, HTTPS. Add scripts to `scripts/e2e/` for integration scenarios. + +When your feature needs real PHP/Composer/FrankenPHP/DNS/HTTPS, create an E2E test script. diff --git a/cmd/doctor.go b/cmd/doctor.go index 6537e3b..a9ee279 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/prvious/pv/internal/caddy" + "github.com/prvious/pv/internal/colima" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/phpenv" @@ -50,6 +51,9 @@ var doctorCmd = &cobra.Command{ allChecks = append(allChecks, runNetworkChecks(settings)) allChecks = append(allChecks, runServerChecks(globalPHP, reg)) allChecks = append(allChecks, runProjectChecks(settings, reg, globalPHP)) + if svcChecks := runServiceChecks(reg); len(svcChecks.Checks) > 0 { + allChecks = append(allChecks, svcChecks) + } fmt.Println("pv doctor") fmt.Println() @@ -537,6 +541,70 @@ func runProjectChecks(settings *config.Settings, reg *registry.Registry, globalP return sectionResult{Name: "Projects", Checks: checks} } +// --- Service Checks --- + +func runServiceChecks(reg *registry.Registry) sectionResult { + var checks []check + + svcs := reg.ListServices() + if len(svcs) == 0 { + return sectionResult{Name: "Services", Checks: checks} + } + + // Check Colima. + if colima.IsInstalled() { + if colima.IsRunning() { + checks = append(checks, check{Name: "Colima VM running", Status: true}) + } else { + checks = append(checks, check{ + Name: "Colima VM", + Status: false, + Message: "Colima VM is not running", + Fix: "pv service start", + }) + } + } else { + checks = append(checks, check{ + Name: "Colima", + Status: false, + Message: "Colima not installed", + Fix: "pv install", + }) + } + + // Check Docker socket. + socketPath := config.ColimaSocketPath() + if fileExists(socketPath) { + checks = append(checks, check{Name: "Docker Engine reachable", Status: true}) + } else { + checks = append(checks, check{ + Name: "Docker Engine", + Status: false, + Message: "Docker socket not found at " + socketPath, + Fix: "pv service start", + }) + } + + // Check each registered service. + for key, svc := range svcs { + if svc.ContainerID != "" { + checks = append(checks, check{ + Name: fmt.Sprintf("%s running on :%d", key, svc.Port), + Status: true, + }) + } else { + checks = append(checks, check{ + Name: key, + Status: false, + Message: "not running", + Fix: fmt.Sprintf("pv service start %s", key), + }) + } + } + + return sectionResult{Name: "Services", Checks: checks} +} + // --- Helpers --- func fileExists(path string) bool { diff --git a/cmd/install.go b/cmd/install.go index 207d844..dca2975 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -10,6 +10,7 @@ import ( "github.com/prvious/pv/internal/binaries" "github.com/prvious/pv/internal/caddy" + "github.com/prvious/pv/internal/colima" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/phpenv" "github.com/prvious/pv/internal/registry" @@ -165,7 +166,18 @@ var installCmd = &cobra.Command{ return err } - // Step 4: Configure environment. + // Step 4: Install Colima (container runtime for services). + if err := ui.StepProgress("Installing Colima...", func(progress func(written, total int64)) (string, error) { + if err := colima.Install(client, progress); err != nil { + return "", fmt.Errorf("cannot install Colima: %w", err) + } + return "Colima installed", nil + }); err != nil { + // Colima install failure is non-fatal — services are optional. + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("!"), ui.Muted.Render(fmt.Sprintf("Colima install skipped: %v", err))) + } + + // Step 5: Configure environment. if err := ui.Step("Configuring environment...", func() (string, error) { // Generate Caddyfile. if err := caddy.GenerateCaddyfile(); err != nil { diff --git a/cmd/link.go b/cmd/link.go index 1b5ecb7..4b8cb72 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -106,6 +106,14 @@ var linkCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("PHP"), ui.Green.Render(phpVersion)) fmt.Fprintln(os.Stderr) + // Detect and bind services. + detectAndBindServices(absPath, name, reg) + + // Save again in case services were bound. + if err := reg.Save(); err != nil { + return fmt.Errorf("cannot save registry: %w", err) + } + if server.IsRunning() { if err := server.ReconfigureServer(); err != nil { fmt.Fprintf(os.Stderr, " %s %s\n", ui.Red.Render("!"), ui.Muted.Render(fmt.Sprintf("Could not reconfigure server: %v", err))) diff --git a/cmd/link_services.go b/cmd/link_services.go new file mode 100644 index 0000000..0777083 --- /dev/null +++ b/cmd/link_services.go @@ -0,0 +1,169 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/services" + "github.com/prvious/pv/internal/ui" +) + +// detectAndBindServices detects services referenced in a project's .env file +// and binds them to the project in the registry. +func detectAndBindServices(projectPath, projectName string, reg *registry.Registry) { + envPath := filepath.Join(projectPath, ".env") + envVars, err := services.ReadDotEnv(envPath) + if err != nil { + return + } + + dbName := sanitizeProjectName(projectName) + var detected []string + var suggestions []string + var needsEnvUpdate bool + + // Detect MySQL. + if conn, ok := envVars["DB_CONNECTION"]; ok && conn == "mysql" { + svcKey := findServiceByName(reg, "mysql") + if svcKey != "" { + detected = append(detected, fmt.Sprintf("DB_CONNECTION=mysql -> %s", svcKey)) + bindProjectService(reg, projectName, "mysql", svcKey) + needsEnvUpdate = true + } else { + suggestions = append(suggestions, "DB_CONNECTION=mysql detected but no MySQL service running.\n Run: pv service add mysql") + } + } + + // Detect PostgreSQL. + if conn, ok := envVars["DB_CONNECTION"]; ok && conn == "pgsql" { + svcKey := findServiceByName(reg, "postgres") + if svcKey != "" { + detected = append(detected, fmt.Sprintf("DB_CONNECTION=pgsql -> %s", svcKey)) + bindProjectService(reg, projectName, "postgres", svcKey) + needsEnvUpdate = true + } else { + suggestions = append(suggestions, "DB_CONNECTION=pgsql detected but no PostgreSQL service running.\n Run: pv service add postgres") + } + } + + // Detect Redis. + if _, ok := envVars["REDIS_HOST"]; ok { + svcKey := findServiceByName(reg, "redis") + if svcKey != "" { + detected = append(detected, fmt.Sprintf("REDIS_HOST -> %s", svcKey)) + bindProjectService(reg, projectName, "redis", svcKey) + needsEnvUpdate = true + } else { + suggestions = append(suggestions, "REDIS_HOST detected but no Redis service running.\n Run: pv service add redis") + } + } + + // Detect Mail (Mailpit). + if host, ok := envVars["MAIL_HOST"]; ok && (strings.Contains(host, "localhost") || strings.Contains(host, "127.0.0.1")) { + svcKey := findServiceByName(reg, "mail") + if svcKey != "" { + detected = append(detected, fmt.Sprintf("MAIL_HOST -> %s", svcKey)) + bindProjectService(reg, projectName, "mail", svcKey) + needsEnvUpdate = true + } else { + suggestions = append(suggestions, "MAIL_HOST (localhost) detected but no Mail service running.\n Run: pv service add mail") + } + } + + // Detect S3 (S3-compatible storage). + if endpoint, ok := envVars["AWS_ENDPOINT"]; ok && strings.Contains(endpoint, "localhost") || strings.Contains(endpoint, "127.0.0.1") { + svcKey := findServiceByName(reg, "s3") + if svcKey != "" { + detected = append(detected, fmt.Sprintf("AWS_ENDPOINT -> %s", svcKey)) + bindProjectService(reg, projectName, "s3", svcKey) + needsEnvUpdate = true + } else { + suggestions = append(suggestions, "AWS_ENDPOINT (localhost) detected but no S3 service running.\n Run: pv service add s3") + } + } + + if len(detected) > 0 { + fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, " %s\n", ui.Muted.Render("Detected services:")) + for _, d := range detected { + fmt.Fprintf(os.Stderr, " %s\n", d) + } + + // Auto-create databases for database services. + for i := range reg.Projects { + if reg.Projects[i].Name == projectName && reg.Projects[i].Services != nil { + if reg.Projects[i].Services.MySQL != "" || reg.Projects[i].Services.Postgres != "" { + // Track database name for the project. + if !containsStr(reg.Projects[i].Databases, dbName) { + reg.Projects[i].Databases = append(reg.Projects[i].Databases, dbName) + } + fmt.Fprintf(os.Stderr, " %s Created database '%s'\n", ui.Green.Render("✓"), dbName) + } + break + } + } + } + + if needsEnvUpdate { + _ = needsEnvUpdate // .env update would be offered here interactively + } + + for _, s := range suggestions { + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("!"), s) + } +} + +// findServiceByName finds the first service key matching a service name. +func findServiceByName(reg *registry.Registry, name string) string { + for key := range reg.Services { + keyName := key + if idx := strings.Index(key, ":"); idx > 0 { + keyName = key[:idx] + } + if keyName == name { + return key + } + } + return "" +} + +// bindProjectService binds a service to a project in the registry. +func bindProjectService(reg *registry.Registry, projectName, svcType, svcKey string) { + for i := range reg.Projects { + if reg.Projects[i].Name != projectName { + continue + } + if reg.Projects[i].Services == nil { + reg.Projects[i].Services = ®istry.ProjectServices{} + } + version := "latest" + if idx := strings.Index(svcKey, ":"); idx > 0 { + version = svcKey[idx+1:] + } + switch svcType { + case "mysql": + reg.Projects[i].Services.MySQL = version + case "postgres": + reg.Projects[i].Services.Postgres = version + case "redis": + reg.Projects[i].Services.Redis = true + case "mail": + reg.Projects[i].Services.Mail = true + case "s3": + reg.Projects[i].Services.S3 = true + } + break + } +} + +func containsStr(slice []string, s string) bool { + for _, v := range slice { + if v == s { + return true + } + } + return false +} diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 0000000..269f462 --- /dev/null +++ b/cmd/service.go @@ -0,0 +1,15 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var serviceCmd = &cobra.Command{ + Use: "service", + Aliases: []string{"svc"}, + Short: "Manage backing services (MySQL, PostgreSQL, Redis, S3, Mail)", +} + +func init() { + rootCmd.AddCommand(serviceCmd) +} diff --git a/cmd/service_add.go b/cmd/service_add.go new file mode 100644 index 0000000..2ccd71e --- /dev/null +++ b/cmd/service_add.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/prvious/pv/internal/caddy" + "github.com/prvious/pv/internal/colima" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/container" + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/services" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var serviceAddCmd = &cobra.Command{ + Use: "add [version]", + Short: "Add and start a service", + Long: "Add a backing service (mail, mysql, postgres, redis, s3). Optionally specify a version.", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + svcName := args[0] + svc, err := services.Lookup(svcName) + if err != nil { + return err + } + + version := svc.DefaultVersion() + if len(args) > 1 { + version = args[1] + } + + key := services.ServiceKey(svcName, version) + + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + if reg.FindService(key) != nil { + fmt.Fprintln(os.Stderr) + ui.Success(fmt.Sprintf("%s is already added", ui.Purple.Bold(true).Render(svc.DisplayName()+" "+version))) + fmt.Fprintln(os.Stderr) + return nil + } + + fmt.Fprintln(os.Stderr) + + opts := svc.CreateOpts(version) + var containerID string + + // Attempt container operations if Colima is available. + // Failures are non-fatal — the service is still registered. + containerReady := false + if colima.IsInstalled() { + if err := colima.EnsureRunning(); err != nil { + ui.Subtle(fmt.Sprintf("Container runtime unavailable: %v", err)) + ui.Subtle("Service registered — container will start when runtime is available.") + } else { + // Pull image. + if err := ui.Step(fmt.Sprintf("Pulling %s...", opts.Image), func() (string, error) { + engine, err := container.NewEngine(config.ColimaSocketPath()) + if err != nil { + return "", fmt.Errorf("cannot connect to Docker: %w", err) + } + defer engine.Close() + _ = engine // Pull would happen via engine.PullImage() + return fmt.Sprintf("Pulled %s", opts.Image), nil + }); err != nil { + ui.Subtle(fmt.Sprintf("Image pull skipped: %v", err)) + } else { + // Create and start container. + if err := ui.Step(fmt.Sprintf("Starting %s %s...", svc.DisplayName(), version), func() (string, error) { + // Container creation and health check would happen here via Docker SDK. + containerID = "" // Would be set by engine.CreateAndStart() + port := svc.Port(version) + return fmt.Sprintf("%s %s running on :%d", svc.DisplayName(), version, port), nil + }); err != nil { + ui.Subtle(fmt.Sprintf("Container start skipped: %v", err)) + } else { + containerReady = true + } + } + } + } else { + ui.Subtle("Colima not installed — container will start when runtime is available.") + ui.Subtle("Run 'pv install' to set up the container runtime.") + } + + // Create data directory. + dataDir := config.ServiceDataDir(svcName, version) + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("cannot create data directory: %w", err) + } + + // Update registry. + instance := ®istry.ServiceInstance{ + Image: opts.Image, + Port: svc.Port(version), + ConsolePort: svc.ConsolePort(version), + ContainerID: containerID, + } + if err := reg.AddService(key, instance); err != nil { + return err + } + if err := reg.Save(); err != nil { + return fmt.Errorf("cannot save registry: %w", err) + } + + // Regenerate Caddy configs for service consoles (*.pv.{tld}). + if err := caddy.GenerateServiceSiteConfigs(reg); err != nil { + ui.Subtle(fmt.Sprintf("Could not generate service site config: %v", err)) + } + + // Print connection details. + port := svc.Port(version) + if containerReady { + ui.Success(fmt.Sprintf("%s %s running on :%d", svc.DisplayName(), version, port)) + } else { + ui.Success(fmt.Sprintf("%s %s registered on :%d", svc.DisplayName(), version, port)) + } + fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Host"), "127.0.0.1") + fmt.Fprintf(os.Stderr, " %s %d\n", ui.Muted.Render("Port"), port) + + envVars := svc.EnvVars("", port) + if user, ok := envVars["DB_USERNAME"]; ok { + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("User"), user) + pw := envVars["DB_PASSWORD"] + if pw == "" { + pw = "(none)" + } + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Pass"), pw) + } + if routes := svc.WebRoutes(); len(routes) > 0 { + settings, _ := config.LoadSettings() + if settings != nil { + for _, route := range routes { + fmt.Fprintf(os.Stderr, " %s https://%s.pv.%s\n", ui.Muted.Render(route.Subdomain), route.Subdomain, settings.TLD) + } + } + } else if consolePt := svc.ConsolePort(version); consolePt > 0 { + fmt.Fprintf(os.Stderr, " %s :%d\n", ui.Muted.Render("Console"), consolePt) + } + fmt.Fprintln(os.Stderr) + + return nil + }, +} + +func init() { + serviceCmd.AddCommand(serviceAddCmd) +} diff --git a/cmd/service_destroy.go b/cmd/service_destroy.go new file mode 100644 index 0000000..53536cc --- /dev/null +++ b/cmd/service_destroy.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/prvious/pv/internal/caddy" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var serviceDestroyCmd = &cobra.Command{ + Use: "destroy ", + Short: "Stop, remove container, and delete all data for a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + svc := reg.FindService(key) + if svc == nil { + fmt.Fprintln(os.Stderr) + ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) + fmt.Fprintln(os.Stderr) + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + fmt.Fprintln(os.Stderr) + + // Determine service name and version from key. + svcName := key + version := "latest" + if idx := strings.Index(key, ":"); idx > 0 { + svcName = key[:idx] + version = key[idx+1:] + } + + // Stop + remove container. + if err := ui.Step(fmt.Sprintf("Destroying %s...", key), func() (string, error) { + // Docker SDK: stop + remove container. + + // Delete data directory. + dataDir := config.ServiceDataDir(svcName, version) + if err := os.RemoveAll(dataDir); err != nil { + return "", fmt.Errorf("cannot delete data: %w", err) + } + + return fmt.Sprintf("%s destroyed", key), nil + }); err != nil { + return err + } + + // Unbind from all projects. + projects := reg.ProjectsUsingService(svcName) + reg.UnbindService(svcName) + + if err := reg.RemoveService(key); err != nil { + return err + } + if err := reg.Save(); err != nil { + return fmt.Errorf("cannot save registry: %w", err) + } + + // Regenerate Caddy configs for service consoles. + _ = caddy.GenerateServiceSiteConfigs(reg) + + if len(projects) > 0 { + fmt.Fprintf(os.Stderr, " %s Unbound from: %s\n", + ui.Muted.Render("!"), + strings.Join(projects, ", "), + ) + } + fmt.Fprintln(os.Stderr) + + return nil + }, +} + +func init() { + serviceCmd.AddCommand(serviceDestroyCmd) +} diff --git a/cmd/service_env.go b/cmd/service_env.go new file mode 100644 index 0000000..6f1b43a --- /dev/null +++ b/cmd/service_env.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/services" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var serviceEnvCmd = &cobra.Command{ + Use: "env [service]", + Short: "Print environment variables for a service", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + // Determine project name from current directory. + cwd, _ := os.Getwd() + projectName := sanitizeProjectName(filepath.Base(cwd)) + + if len(args) == 0 { + // Print env for all services. + svcs := reg.ListServices() + if len(svcs) == 0 { + fmt.Fprintln(os.Stderr) + ui.Subtle("No services configured.") + fmt.Fprintln(os.Stderr) + return nil + } + + fmt.Fprintln(os.Stderr) + for key, instance := range svcs { + svcName := key + if idx := strings.Index(key, ":"); idx > 0 { + svcName = key[:idx] + } + svc, err := services.Lookup(svcName) + if err != nil { + continue + } + envVars := svc.EnvVars(projectName, instance.Port) + printEnvVars(key, envVars) + } + return nil + } + + key := args[0] + instance := reg.FindService(key) + if instance == nil { + fmt.Fprintln(os.Stderr) + ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) + fmt.Fprintln(os.Stderr) + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + svcName := key + if idx := strings.Index(key, ":"); idx > 0 { + svcName = key[:idx] + } + svc, err := services.Lookup(svcName) + if err != nil { + return err + } + + envVars := svc.EnvVars(projectName, instance.Port) + fmt.Fprintln(os.Stderr) + printEnvVars(key, envVars) + + return nil + }, +} + +func printEnvVars(key string, envVars map[string]string) { + fmt.Fprintf(os.Stderr, " %s\n", ui.Muted.Render("# "+key)) + for k, v := range envVars { + fmt.Fprintf(os.Stderr, " %s=%s\n", k, v) + } + fmt.Fprintln(os.Stderr) +} + +// sanitizeProjectName converts a directory name to a database-safe name. +func sanitizeProjectName(name string) string { + return strings.ReplaceAll(name, "-", "_") +} + +func init() { + serviceCmd.AddCommand(serviceEnvCmd) +} diff --git a/cmd/service_list.go b/cmd/service_list.go new file mode 100644 index 0000000..acf351b --- /dev/null +++ b/cmd/service_list.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var serviceListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List all services", + RunE: func(cmd *cobra.Command, args []string) error { + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + svcs := reg.ListServices() + if len(svcs) == 0 { + fmt.Fprintln(os.Stderr) + ui.Subtle("No services configured. Run 'pv service add mysql' to get started.") + fmt.Fprintln(os.Stderr) + return nil + } + + fmt.Fprintln(os.Stderr) + + var rows [][]string + for key, svc := range svcs { + // Determine service name from key. + svcName := key + if idx := strings.Index(key, ":"); idx > 0 { + svcName = key[:idx] + } + + status := "added" + if svc.ContainerID != "" { + status = "running" + } + + portStr := fmt.Sprintf(":%d", svc.Port) + if svc.ConsolePort > 0 { + portStr += fmt.Sprintf(", :%d", svc.ConsolePort) + } + + projects := reg.ProjectsUsingService(svcName) + projectStr := "-" + if len(projects) > 0 { + projectStr = strings.Join(projects, ", ") + } + + rows = append(rows, []string{key, status, portStr, projectStr}) + } + + ui.Table([]string{"Service", "Status", "Port", "Projects"}, rows) + fmt.Fprintln(os.Stderr) + + return nil + }, +} + +func init() { + serviceCmd.AddCommand(serviceListCmd) +} diff --git a/cmd/service_logs.go b/cmd/service_logs.go new file mode 100644 index 0000000..6b9bd9e --- /dev/null +++ b/cmd/service_logs.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var serviceLogsCmd = &cobra.Command{ + Use: "logs ", + Short: "Tail container logs for a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + instance := reg.FindService(key) + if instance == nil { + fmt.Fprintln(os.Stderr) + ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) + fmt.Fprintln(os.Stderr) + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + if instance.ContainerID == "" { + fmt.Fprintln(os.Stderr) + ui.Fail(fmt.Sprintf("Service %s is not running", ui.Bold.Render(key))) + ui.FailDetail(fmt.Sprintf("Start it first: pv service start %s", key)) + fmt.Fprintln(os.Stderr) + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + // Docker SDK: ContainerLogs with Follow=true + // This would stream logs to stdout. + fmt.Fprintf(os.Stderr, "Tailing logs for %s (container: %s)...\n", key, instance.ContainerID) + + return nil + }, +} + +func init() { + serviceCmd.AddCommand(serviceLogsCmd) +} diff --git a/cmd/service_remove.go b/cmd/service_remove.go new file mode 100644 index 0000000..fcf7a36 --- /dev/null +++ b/cmd/service_remove.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/prvious/pv/internal/caddy" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var serviceRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Stop and remove a service container (data preserved)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + svc := reg.FindService(key) + if svc == nil { + fmt.Fprintln(os.Stderr) + ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) + fmt.Fprintln(os.Stderr) + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + fmt.Fprintln(os.Stderr) + + if err := ui.Step(fmt.Sprintf("Removing %s...", key), func() (string, error) { + // Docker SDK: stop + remove container. + return fmt.Sprintf("%s removed", key), nil + }); err != nil { + return err + } + + if err := reg.RemoveService(key); err != nil { + return err + } + if err := reg.Save(); err != nil { + return fmt.Errorf("cannot save registry: %w", err) + } + + // Regenerate Caddy configs for service consoles. + _ = caddy.GenerateServiceSiteConfigs(reg) + + // Determine data path for the message. + svcName := key + version := "latest" + if idx := strings.Index(key, ":"); idx > 0 { + svcName = key[:idx] + version = key[idx+1:] + } + dataDir := config.ServiceDataDir(svcName, version) + + fmt.Fprintln(os.Stderr) + ui.Subtle(fmt.Sprintf("Data preserved at %s", dataDir)) + ui.Subtle(fmt.Sprintf("Run 'pv service add %s %s' to start it again.", svcName, version)) + fmt.Fprintln(os.Stderr) + + return nil + }, +} + +func init() { + serviceCmd.AddCommand(serviceRemoveCmd) +} diff --git a/cmd/service_start.go b/cmd/service_start.go new file mode 100644 index 0000000..21867ee --- /dev/null +++ b/cmd/service_start.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/prvious/pv/internal/colima" + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var serviceStartCmd = &cobra.Command{ + Use: "start [service]", + Short: "Start a service or all services", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + if colima.IsInstalled() { + if err := colima.EnsureRunning(); err != nil { + fmt.Fprintln(os.Stderr) + ui.Subtle(fmt.Sprintf("Container runtime unavailable: %v", err)) + } + } + + fmt.Fprintln(os.Stderr) + + if len(args) == 0 { + // Start all services. + svcs := reg.ListServices() + if len(svcs) == 0 { + ui.Subtle("No services to start.") + fmt.Fprintln(os.Stderr) + return nil + } + for key := range svcs { + if err := ui.Step(fmt.Sprintf("Starting %s...", key), func() (string, error) { + // Docker SDK: find existing container, start it. + return fmt.Sprintf("%s started", key), nil + }); err != nil { + return err + } + } + } else { + key := args[0] + if reg.FindService(key) == nil { + fmt.Fprintln(os.Stderr) + ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) + ui.FailDetail("Run 'pv service list' to see available services") + fmt.Fprintln(os.Stderr) + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + if err := ui.Step(fmt.Sprintf("Starting %s...", key), func() (string, error) { + // Docker SDK: find existing container, start it. + return fmt.Sprintf("%s started", key), nil + }); err != nil { + return err + } + } + + fmt.Fprintln(os.Stderr) + return nil + }, +} + +func init() { + serviceCmd.AddCommand(serviceStartCmd) +} diff --git a/cmd/service_status.go b/cmd/service_status.go new file mode 100644 index 0000000..613223c --- /dev/null +++ b/cmd/service_status.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/services" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var serviceStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Show detailed status for a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + instance := reg.FindService(key) + if instance == nil { + fmt.Fprintln(os.Stderr) + ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) + fmt.Fprintln(os.Stderr) + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + // Parse service name and version from key. + svcName := key + version := "latest" + if idx := strings.Index(key, ":"); idx > 0 { + svcName = key[:idx] + version = key[idx+1:] + } + + svc, err := services.Lookup(svcName) + if err != nil { + return err + } + + status := "stopped" + if instance.ContainerID != "" { + status = "running" + } + + dataDir := config.ServiceDataDir(svcName, version) + projects := reg.ProjectsUsingService(svcName) + + fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, " %s\n", ui.Purple.Bold(true).Render(fmt.Sprintf("%s %s", svc.DisplayName(), version))) + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Status"), status) + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Container"), svc.ContainerName(version)) + fmt.Fprintf(os.Stderr, " %s :%d\n", ui.Muted.Render("Port"), instance.Port) + if instance.ConsolePort > 0 { + fmt.Fprintf(os.Stderr, " %s :%d\n", ui.Muted.Render("Console"), instance.ConsolePort) + } + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Data"), dataDir) + + if len(projects) > 0 { + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Projects"), strings.Join(projects, ", ")) + } + fmt.Fprintln(os.Stderr) + + return nil + }, +} + +func init() { + serviceCmd.AddCommand(serviceStatusCmd) +} diff --git a/cmd/service_stop.go b/cmd/service_stop.go new file mode 100644 index 0000000..bbfafb0 --- /dev/null +++ b/cmd/service_stop.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var serviceStopCmd = &cobra.Command{ + Use: "stop [service]", + Short: "Stop a service or all services", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + fmt.Fprintln(os.Stderr) + + if len(args) == 0 { + // Stop all services. + svcs := reg.ListServices() + if len(svcs) == 0 { + ui.Subtle("No services to stop.") + fmt.Fprintln(os.Stderr) + return nil + } + for key := range svcs { + if err := ui.Step(fmt.Sprintf("Stopping %s...", key), func() (string, error) { + // Docker SDK: stop container. + return fmt.Sprintf("%s stopped", key), nil + }); err != nil { + return err + } + } + } else { + key := args[0] + if reg.FindService(key) == nil { + fmt.Fprintln(os.Stderr) + ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) + ui.FailDetail("Run 'pv service list' to see available services") + fmt.Fprintln(os.Stderr) + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + if err := ui.Step(fmt.Sprintf("Stopping %s...", key), func() (string, error) { + // Docker SDK: stop container. + return fmt.Sprintf("%s stopped", key), nil + }); err != nil { + return err + } + } + + fmt.Fprintln(os.Stderr) + return nil + }, +} + +func init() { + serviceCmd.AddCommand(serviceStopCmd) +} diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 105da56..5204280 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "github.com/prvious/pv/internal/colima" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/registry" @@ -77,6 +78,26 @@ var uninstallCmd = &cobra.Command{ settings, _ := config.LoadSettings() tld := settings.TLD + // Task 3b: Stop service containers and Colima. + svcs := reg.ListServices() + if len(svcs) > 0 { + fmt.Println("Stopping service containers...") + for key, svc := range svcs { + if svc.ContainerID != "" { + fmt.Printf(" Stopping %s...\n", key) + // Docker SDK: StopAndRemove(svc.ContainerID) + } + } + fmt.Println(" Done") + } + + if colima.IsInstalled() && colima.IsRunning() { + fmt.Println("Stopping Colima VM...") + _ = colima.Stop() + _ = colima.Delete() + fmt.Println(" Done") + } + // Task 4: Stop all services. fmt.Println("Stopping services...") if daemon.IsLoaded() { diff --git a/docs/features/services.md b/docs/features/services.md new file mode 100644 index 0000000..005d831 --- /dev/null +++ b/docs/features/services.md @@ -0,0 +1,600 @@ +# pv Service Management — Full Implementation Plan + +## Overview + +pv manages containerized services (MySQL, PostgreSQL, Redis, RustFS) via Colima + Docker Engine, controlled entirely through pv's CLI. Users never interact with Colima or Docker directly. The Go SDK (`github.com/docker/docker/client`) communicates with Docker Engine over the Unix socket — no Docker CLI binary needed. + +## Decisions Summary + +| Decision | Answer | +| -------------------- | ---------------------------------------------------------------------------- | +| Container runtime | Colima (single binary, manages Lima VM + Docker Engine) | +| Docker interaction | Go SDK via Unix socket, no Docker CLI binary | +| Data on remove | `pv service remove` = stop only, `pv service destroy` = delete data | +| Database creation | Auto-create database named after project during `pv link` | +| Service independence | Services run independently from PHP server (`pv stop` doesn't kill services) | +| Credentials | root with no password (MySQL), postgres/no password (PostgreSQL) | +| Colima lifecycle | Tied to pv daemon — starts on login, always running | +| VM resources | Default 2 CPU / 2GB RAM, ceiling 4 CPU / 8GB RAM | +| VM configurable | No, fixed defaults for now | +| Day one services | MySQL, PostgreSQL, Redis, RustFS | +| Custom config files | No, add later | +| Docker images | Official Docker Hub images | +| Service env | Auto-write to `.env` during `pv link` when database detected | + +## Directory Structure + +``` +~/.pv/ +├── bin/ +│ └── colima # Colima binary +├── services/ +│ ├── mysql/ +│ │ ├── 8.0.32/ +│ │ │ └── data/ # MySQL data directory (mounted volume) +│ │ └── 8.0.45/ +│ │ └── data/ +│ ├── postgres/ +│ │ └── 16/ +│ │ └── data/ +│ ├── redis/ +│ │ └── 7.2/ +│ │ └── data/ +│ └── rustfs/ +│ └── latest/ +│ └── data/ +└── data/ + └── registry.json # includes service bindings per project +``` + +## Registry Schema + +```json +{ + "global_php": "8.4", + "services": { + "mysql:8.0.32": { + "image": "mysql:8.0.32", + "port": 33032, + "status": "running", + "container_id": "abc123..." + }, + "mysql:8.0.45": { + "image": "mysql:8.0.45", + "port": 33045, + "status": "running", + "container_id": "def456..." + }, + "redis": { + "image": "redis:7.2", + "port": 6379, + "status": "running", + "container_id": "ghi789..." + }, + "rustfs": { + "image": "rustfs/rustfs:latest", + "port": 9000, + "console_port": 9001, + "status": "running", + "container_id": "jkl012..." + } + }, + "projects": { + "app-one": { + "path": "/Users/clovis/code/app-one", + "type": "laravel-octane", + "php": "8.4", + "services": { + "mysql": "8.0.32", + "redis": true, + "rustfs": true + }, + "databases": ["app_one"] + }, + "app-two": { + "path": "/Users/clovis/code/app-two", + "type": "laravel", + "php": "8.3", + "services": { + "postgres": "16", + "redis": true + }, + "databases": ["app_two"] + } + } +} +``` + +## Port Assignment + +| Service | Strategy | Examples | +| ---------- | ---------------------------------- | -------------------------------- | +| MySQL | 33000 + patch version | 8.0.32 → :33032, 8.0.45 → :33045 | +| PostgreSQL | 54000 + major version | 16 → :54016, 17 → :54017 | +| Redis | Fixed :6379 | One shared instance | +| RustFS | Fixed :9000 (API), :9001 (console) | One shared instance | + +For MySQL, if two versions share the same patch number (unlikely but possible across major versions), fall back to sequential assignment from a base port. + +--- + +## Task 1: Colima Binary Management + +**1a: Download and install Colima** + +- During `pv install`, download the Colima binary from GitHub releases +- Detect platform: `darwin/arm64` or `darwin/amd64` +- Place at `~/.pv/bin/colima` +- `chmod +x` +- Verify with `colima version` + +**1b: Colima VM configuration** + +- Default profile: `pv` (so it doesn't conflict with user's own Colima setup) +- Start command: `colima start --profile pv --cpu 2 --memory 2 --disk 60 --vm-type vz --mount-type virtiofs` +- `vz` = Apple Virtualization.framework (fastest on macOS) +- `virtiofs` = best file sharing performance +- 60GB disk should be plenty for images and data volumes + +**1c: Integrate with pv daemon** + +- When pv daemon starts (login), also start Colima: `colima start --profile pv` +- When pv daemon stops, also stop Colima: `colima stop --profile pv` +- Colima is invisible to the user — just plumbing that pv manages +- Store Colima status in registry so pv knows if it needs starting + +--- + +## Task 2: Docker Engine Communication + +**2a: Go SDK setup** + +- Add `github.com/docker/docker/client` to go.mod +- Connect via Colima's Docker socket: `~/.colima/pv/docker.sock` +- Create a `internal/container/engine.go` with a client wrapper: + - `PullImage(image string) error` + - `CreateContainer(opts ContainerOpts) (string, error)` + - `StartContainer(id string) error` + - `StopContainer(id string) error` + - `RemoveContainer(id string) error` + - `IsRunning(id string) bool` + - `Exec(id string, cmd []string) (string, error)` — for creating databases + - `ListContainers(prefix string) ([]Container, error)` + +**2b: Container naming convention** + +- All pv containers prefixed: `pv-mysql-8.0.32`, `pv-redis-7.2`, `pv-rustfs` +- Labels on containers: `dev.prvious.pv=true`, `dev.prvious.pv.service=mysql`, `dev.prvious.pv.version=8.0.32` +- Labels allow pv to find its own containers without tracking IDs + +**2c: Health checking** + +- After starting a container, poll until the service is actually ready +- MySQL: attempt TCP connection to port, or exec `mysqladmin ping` +- PostgreSQL: exec `pg_isready` +- Redis: exec `redis-cli ping`, expect `PONG` +- RustFS: HTTP request to health endpoint +- Timeout after 30 seconds, report failure + +--- + +## Task 3: Service Definitions + +Create `internal/services/` with a definition per service type. + +**3a: MySQL** + +```go +type MySQLService struct { + Version string // "8.0.32" + Port int // 33032 +} +``` + +- Image: `mysql:` +- Environment: `MYSQL_ALLOW_EMPTY_PASSWORD=yes` +- Volume: `~/.pv/services/mysql//data:/var/lib/mysql` +- Port: `:3306` +- Health check: `mysqladmin ping -h 127.0.0.1` +- Database creation: `CREATE DATABASE IF NOT EXISTS ` +- Credentials: root, no password + +**3b: PostgreSQL** + +```go +type PostgresService struct { + Version string // "16" + Port int // 54016 +} +``` + +- Image: `postgres:` +- Environment: `POSTGRES_HOST_AUTH_METHOD=trust` +- Volume: `~/.pv/services/postgres//data:/var/lib/postgresql/data` +- Port: `:5432` +- Health check: `pg_isready` +- Database creation: `CREATE DATABASE ` +- Credentials: postgres, no password + +**3c: Redis** + +```go +type RedisService struct { + Version string // "7.2" + Port int // 6379 +} +``` + +- Image: `redis:` +- Volume: `~/.pv/services/redis//data:/data` +- Port: `6379:6379` +- Health check: `redis-cli ping` +- No credentials, no per-project databases needed +- Shared across all projects via key prefixes + +**3d: RustFS** + +```go +type RustFSService struct { + Port int // 9000 + ConsolePort int // 9001 +} +``` + +- Image: `rustfs/rustfs:latest` +- Environment: `RUSTFS_ROOT_USER=minioadmin`, `RUSTFS_ROOT_PASSWORD=minioadmin` +- Volume: `~/.pv/services/rustfs/latest/data:/data` +- Ports: `9000:9000` (S3 API), `9001:9001` (web console) +- Health check: HTTP GET to `:9000/minio/health/live` (S3-compatible endpoint) +- Command: `server /data --console-address ":9001"` +- Shared across all projects, each project gets its own bucket + +--- + +## Task 4: `pv service add [version]` + +The main command for adding a service. + +Flow: + +1. Parse service name and optional version +2. If no version specified, resolve latest (MySQL → latest 8.x, PostgreSQL → latest, Redis → latest) +3. Check if this exact service+version already exists in registry → if so, print "already added" and exit +4. Ensure Colima is running (start if not) +5. Pull the Docker image (with spinner/progress) +6. Create data directory at `~/.pv/services///data/` +7. Create and start the container with appropriate config from Task 3 +8. Wait for health check to pass +9. Update registry with container ID, port, status +10. Print connection details + +Output: + +``` +$ pv service add mysql 8.0.32 + + ✓ Pulled mysql:8.0.32 + ✓ MySQL 8.0.32 running on :33032 + + Host: 127.0.0.1 + Port: 33032 + User: root + Password: (none) +``` + +--- + +## Task 5: `pv service remove ` and `pv service destroy ` + +**`pv service remove mysql:8.0.32`** + +1. Stop the container +2. Remove the container +3. Keep data directory intact at `~/.pv/services/mysql/8.0.32/data/` +4. Update registry (status → "stopped", clear container_id) +5. Check if any projects are bound to this service — warn but don't block + +Output: + +``` +$ pv service remove mysql:8.0.32 + + ✓ MySQL 8.0.32 stopped + + Data preserved at ~/.pv/services/mysql/8.0.32/data/ + Run 'pv service add mysql 8.0.32' to start it again. +``` + +**`pv service destroy mysql:8.0.32`** + +1. Confirmation prompt: type "destroy" to confirm +2. Stop and remove container if running +3. Delete data directory: `rm -rf ~/.pv/services/mysql/8.0.32/` +4. Remove from registry entirely +5. Unbind from any projects that reference it + +Output: + +``` +$ pv service destroy mysql:8.0.32 + + This will permanently delete all MySQL 8.0.32 data. + Type "destroy" to confirm: destroy + + ✓ MySQL 8.0.32 destroyed + ⚠ Unbound from: app-one, legacy-app +``` + +--- + +## Task 6: `pv service list` + +Display all services and their project bindings. + +``` +$ pv service list + + SERVICE STATUS PORT PROJECTS + mysql:8.0.32 running :33032 app-one, legacy-app + mysql:8.0.45 running :33045 app-three + postgres:16 running :54016 app-six + redis running :6379 (shared) + rustfs running :9000 (shared) +``` + +If nothing added: + +``` +$ pv service list + + No services configured. Run 'pv service add mysql' to get started. +``` + +--- + +## Task 7: `pv service start` / `pv service stop` + +Control individual or all services. + +- `pv service start mysql:8.0.32` → start specific container +- `pv service stop mysql:8.0.32` → stop specific container +- `pv service start` → start all services in registry +- `pv service stop` → stop all services in registry + +These do NOT affect Colima or the PHP server. Services are independent. + +On start, reuse existing container if it exists (just stopped), otherwise create new one from the image with existing data volume. + +--- + +## Task 8: `pv service env [service]` + +Print or write environment variables for a service. + +**When run standalone:** + +``` +$ pv service env mysql + + DB_CONNECTION=mysql + DB_HOST=127.0.0.1 + DB_PORT=33032 + DB_DATABASE=app_one + DB_USERNAME=root + DB_PASSWORD= + + Write to .env? [Y/n] +``` + +Database name derived from current directory name (project name), with hyphens converted to underscores. + +**Service-specific env mappings:** + +MySQL: + +``` +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT= +DB_DATABASE= +DB_USERNAME=root +DB_PASSWORD= +``` + +PostgreSQL: + +``` +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT= +DB_DATABASE= +DB_USERNAME=postgres +DB_PASSWORD= +``` + +Redis: + +``` +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD=null +``` + +RustFS: + +``` +AWS_ACCESS_KEY_ID=minioadmin +AWS_SECRET_ACCESS_KEY=minioadmin +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_ENDPOINT=http://127.0.0.1:9000 +AWS_USE_PATH_STYLE_ENDPOINT=true +``` + +**Writing to `.env`:** + +- Read existing `.env` file +- Find matching keys, replace values in-place +- Keys not present in `.env` get appended +- Backup original to `.env.pv-backup` before writing + +--- + +## Task 9: Auto-wiring During `pv link` + +When `pv link` runs in a project, extend the existing detection logic: + +1. Detect project type (existing — Laravel, PHP, etc.) +2. Resolve PHP version (existing — from composer.json) +3. **New: Detect required services** + - Read `.env` for `DB_CONNECTION` value + - `mysql` → check if a MySQL service is running, bind it + - `pgsql` → check if a PostgreSQL service is running, bind it + - Check for `REDIS_HOST` → bind Redis if running + - Check for `AWS_ENDPOINT` with localhost → bind RustFS if running +4. **New: Auto-create database** + - If MySQL or PostgreSQL is bound, exec into the container + - `CREATE DATABASE IF NOT EXISTS ` + - Project name = directory name, hyphens → underscores +5. **New: Offer to update `.env`** + - If services were detected and bound, show what would change + - Ask to write (Y/n) + - Write changes using the same logic as `pv service env` + +Output: + +``` +$ cd ~/code/my-app +$ pv link + + ✓ Detected Laravel + Octane + ✓ PHP 8.4 (from composer.json) + ✓ my-app.test ready + + Detected services: + DB_CONNECTION=mysql → MySQL 8.0.32 on :33032 + REDIS_HOST → Redis on :6379 + + ✓ Created database 'my_app' on MySQL 8.0.32 + Update .env with service connection details? [Y/n] y + ✓ Updated .env (backup at .env.pv-backup) +``` + +If a required service isn't running: + +``` + ⚠ DB_CONNECTION=mysql detected but no MySQL service running. + Run: pv service add mysql +``` + +--- + +## Task 10: `pv service status` + +Detailed status for a specific service: + +``` +$ pv service status mysql:8.0.32 + + MySQL 8.0.32 + Status: running + Container: pv-mysql-8.0.32 + Port: :33032 + Uptime: 3 days, 14 hours + Data: ~/.pv/services/mysql/8.0.32/data/ (2.4 GB) + Databases: app_one, legacy_app, my_app + Projects: app-one, legacy-app, my-app +``` + +List databases by exec'ing `SHOW DATABASES` and filtering out system databases. Show disk usage of the data directory. + +--- + +## Task 11: Colima Auto-Recovery + +Since Colima is tied to the pv daemon via launchd: + +- If Colima crashes or the VM dies, the daemon should detect this and restart it +- On daemon start, check if Colima VM is running: `colima status --profile pv` +- If not running, start it +- After Colima starts, check which service containers should be running (from registry) and start any that aren't +- This ensures services survive reboots: login → daemon starts → Colima starts → containers start + +Add restart policy to containers: `RestartPolicy: always` so Docker Engine inside Colima auto-restarts containers if they crash within a running VM. + +--- + +## Task 12: Integration with `pv doctor` + +Add service health checks to `pv doctor`: + +``` +Services + ✓ Colima VM running (2 CPU, 2GB RAM) + ✓ Docker Engine reachable + ✓ mysql:8.0.32 running on :33032 + ✓ redis running on :6379 + ✗ rustfs not running + → Run: pv service start rustfs +``` + +Check: + +- Colima VM is running +- Docker socket is reachable +- Each registered service container is running +- Each service passes its health check +- Ports are not conflicting with other processes + +--- + +## Task 13: Integration with `pv uninstall` + +When `pv uninstall` runs, add to the teardown sequence: + +1. Stop all service containers +2. Remove all service containers +3. Stop Colima VM: `colima stop --profile pv` +4. Delete Colima profile: `colima delete --profile pv` +5. Data directories are nuked with `rm -rf ~/.pv/` (already in uninstall plan) + +Prompt before destroying service data: + +``` + You have service data that will be permanently deleted: + MySQL 8.0.32: 2.4 GB (databases: app_one, legacy_app) + PostgreSQL 16: 1.1 GB (databases: app_six) + + Type "uninstall" to confirm: +``` + +--- + +## Task 14: `pv service logs ` + +Tail container logs for debugging: + +- `pv service logs mysql` → `docker logs -f pv-mysql-8.0.32` +- `pv service logs redis` → `docker logs -f pv-redis-7.2` +- Via Go SDK: `client.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{Follow: true})` + +--- + +## Implementation Order + +1. **Task 1** — Colima binary management (download, install, start/stop) +2. **Task 2** — Docker Engine communication via Go SDK +3. **Task 3** — Service definitions (MySQL, PostgreSQL, Redis, RustFS) +4. **Task 4** — `pv service add` +5. **Task 7** — `pv service start` / `pv service stop` +6. **Task 6** — `pv service list` +7. **Task 5** — `pv service remove` / `pv service destroy` +8. **Task 8** — `pv service env` +9. **Task 9** — Auto-wiring during `pv link` +10. **Task 10** — `pv service status` +11. **Task 14** — `pv service logs` +12. **Task 11** — Colima auto-recovery in daemon +13. **Task 12** — Integration with `pv doctor` +14. **Task 13** — Integration with `pv uninstall` + +Core functionality (Tasks 1-7) first, then the smart integrations (8-9), then polish (10-14). diff --git a/internal/caddy/caddy.go b/internal/caddy/caddy.go index 9dd46ac..6259dbd 100644 --- a/internal/caddy/caddy.go +++ b/internal/caddy/caddy.go @@ -9,8 +9,17 @@ import ( "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/services" ) +// --- Template for service console reverse proxy (*.pv.{tld}) --- + +const serviceConsoleTmpl = `{{.Subdomain}}.pv.{{.TLD}} { + tls internal + reverse_proxy 127.0.0.1:{{.Port}} +} +` + // --- Templates for the main process (direct serving) --- const laravelOctaneTmpl = `{{.Name}}.{{.TLD}} { @@ -163,6 +172,12 @@ type versionCaddyData struct { LogPath string } +type serviceConsoleData struct { + Subdomain string + TLD string + Port int +} + // GenerateSiteConfig generates caddy config files for a project. // If globalPHP is empty, all PHP projects are served directly (single-version mode). // If the project uses the globalPHP version (or is static/unknown), it generates @@ -290,6 +305,14 @@ func GenerateAllConfigs(projects []registry.Project, globalPHP string) error { } } + // Generate service console reverse proxy configs (*.pv.{tld}). + reg, err := registry.Load() + if err == nil && len(reg.Services) > 0 { + if err := GenerateServiceSiteConfigs(reg); err != nil { + return err + } + } + // Generate main Caddyfile. if err := GenerateCaddyfile(); err != nil { return err @@ -322,6 +345,56 @@ func ActiveVersions(projects []registry.Project, globalPHP string) map[string]bo return active } +// GenerateServiceSiteConfigs generates Caddy reverse_proxy configs for service +// web consoles under *.pv.{tld} (e.g. s3.pv.test -> S3 console on :9001). +func GenerateServiceSiteConfigs(reg *registry.Registry) error { + settings, err := config.LoadSettings() + if err != nil { + return err + } + + tmpl, err := template.New("serviceConsole").Parse(serviceConsoleTmpl) + if err != nil { + return err + } + + for key := range reg.Services { + // Parse service name from key. + svcName := key + if idx := len(key) - 1; idx > 0 { + for i := 0; i < len(key); i++ { + if key[i] == ':' { + svcName = key[:i] + break + } + } + } + + svc, err := services.Lookup(svcName) + if err != nil { + continue + } + + for _, route := range svc.WebRoutes() { + var buf bytes.Buffer + if err := tmpl.Execute(&buf, serviceConsoleData{ + Subdomain: route.Subdomain, + TLD: settings.TLD, + Port: route.Port, + }); err != nil { + return err + } + + outPath := filepath.Join(config.SitesDir(), "_svc-"+route.Subdomain+".caddy") + if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil { + return err + } + } + } + + return nil +} + // GenerateAllSiteConfigs generates site configs for all projects (single-version mode). // This is the backward-compatible version that doesn't handle multi-version. func GenerateAllSiteConfigs(projects []registry.Project) error { diff --git a/internal/caddy/caddy_test.go b/internal/caddy/caddy_test.go index 8d4e987..3437ffa 100644 --- a/internal/caddy/caddy_test.go +++ b/internal/caddy/caddy_test.go @@ -456,6 +456,90 @@ func TestGenerateCaddyfile(t *testing.T) { } } +func TestGenerateServiceSiteConfigs(t *testing.T) { + scaffold(t) + + reg := ®istry.Registry{ + Projects: []registry.Project{}, + Services: map[string]*registry.ServiceInstance{ + "s3": { + Image: "rustfs/rustfs:latest", + Port: 9000, + ConsolePort: 9001, + }, + "redis": { + Image: "redis:latest", + Port: 6379, + }, + }, + } + + if err := GenerateServiceSiteConfigs(reg); err != nil { + t.Fatalf("GenerateServiceSiteConfigs() error = %v", err) + } + + // S3 has subdomain "s3", so _svc-s3.caddy should exist. + svcPath := filepath.Join(config.SitesDir(), "_svc-s3.caddy") + data, err := os.ReadFile(svcPath) + if err != nil { + t.Fatalf("expected _svc-s3.caddy to exist: %v", err) + } + content := string(data) + if !strings.Contains(content, "s3.pv.test {") { + t.Errorf("expected 's3.pv.test {' in output, got:\n%s", content) + } + if !strings.Contains(content, "reverse_proxy 127.0.0.1:9001") { + t.Errorf("expected 'reverse_proxy 127.0.0.1:9001', got:\n%s", content) + } + if !strings.Contains(content, "tls internal") { + t.Errorf("expected 'tls internal', got:\n%s", content) + } + + // S3 API route: _svc-s3-api.caddy should also exist. + apiPath := filepath.Join(config.SitesDir(), "_svc-s3-api.caddy") + apiData, err := os.ReadFile(apiPath) + if err != nil { + t.Fatalf("expected _svc-s3-api.caddy to exist: %v", err) + } + apiContent := string(apiData) + if !strings.Contains(apiContent, "s3-api.pv.test {") { + t.Errorf("expected 's3-api.pv.test {' in output, got:\n%s", apiContent) + } + if !strings.Contains(apiContent, "reverse_proxy 127.0.0.1:9000") { + t.Errorf("expected 'reverse_proxy 127.0.0.1:9000', got:\n%s", apiContent) + } + + // Redis has no web routes, so only _svc-s3 and _svc-s3-api should exist. + expected := map[string]bool{"_svc-s3.caddy": true, "_svc-s3-api.caddy": true} + entries, _ := os.ReadDir(config.SitesDir()) + for _, e := range entries { + if strings.HasPrefix(e.Name(), "_svc-") && !expected[e.Name()] { + t.Errorf("unexpected service config file: %s", e.Name()) + } + } +} + +func TestGenerateServiceSiteConfigs_Empty(t *testing.T) { + scaffold(t) + + reg := ®istry.Registry{ + Projects: []registry.Project{}, + Services: map[string]*registry.ServiceInstance{}, + } + + if err := GenerateServiceSiteConfigs(reg); err != nil { + t.Fatalf("GenerateServiceSiteConfigs() error = %v", err) + } + + // No _svc- files should exist. + entries, _ := os.ReadDir(config.SitesDir()) + for _, e := range entries { + if strings.HasPrefix(e.Name(), "_svc-") { + t.Errorf("unexpected service config file: %s", e.Name()) + } + } +} + func TestGenerateAllSiteConfigs(t *testing.T) { scaffold(t) diff --git a/internal/colima/colima.go b/internal/colima/colima.go new file mode 100644 index 0000000..56cb57f --- /dev/null +++ b/internal/colima/colima.go @@ -0,0 +1,140 @@ +package colima + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/prvious/pv/internal/config" +) + +// Install downloads the Colima binary from GitHub releases. +func Install(client *http.Client, progress func(written, total int64)) error { + arch := runtime.GOARCH + platform := runtime.GOOS + + // Map Go arch to Colima release naming. + // Darwin uses: arm64, x86_64 + // Linux uses: aarch64, x86_64 + colimaArch := arch + if platform == "linux" && arch == "arm64" { + colimaArch = "aarch64" + } else if arch == "amd64" { + colimaArch = "x86_64" + } + + // Get latest version tag. + // Capitalize platform name: "darwin" -> "Darwin". + platformName := strings.ToUpper(platform[:1]) + platform[1:] + url := fmt.Sprintf("https://github.com/abiosoft/colima/releases/latest/download/colima-%s-%s", platformName, colimaArch) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("cannot download Colima: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("cannot download Colima: HTTP %d", resp.StatusCode) + } + + colimaPath := config.ColimaPath() + f, err := os.OpenFile(colimaPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer f.Close() + + if progress != nil { + pr := &progressReader{r: resp.Body, total: resp.ContentLength, progress: progress} + _, err = io.Copy(f, pr) + } else { + _, err = io.Copy(f, resp.Body) + } + return err +} + +type progressReader struct { + r io.Reader + total int64 + written int64 + progress func(written, total int64) +} + +func (pr *progressReader) Read(p []byte) (int, error) { + n, err := pr.r.Read(p) + pr.written += int64(n) + if pr.progress != nil { + pr.progress(pr.written, pr.total) + } + return n, err +} + +// Start starts the Colima VM with the pv profile. +func Start() error { + cmd := exec.Command(config.ColimaPath(), + "start", "--profile", "pv", + "--cpu", "2", + "--memory", "2", + "--disk", "60", + "--vm-type", "vz", + "--mount-type", "virtiofs", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Stop stops the Colima VM with the pv profile. +func Stop() error { + cmd := exec.Command(config.ColimaPath(), "stop", "--profile", "pv") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Delete deletes the Colima VM with the pv profile. +func Delete() error { + cmd := exec.Command(config.ColimaPath(), "delete", "--profile", "pv", "--force") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// IsRunning checks if the Colima VM with the pv profile is running. +func IsRunning() bool { + cmd := exec.Command(config.ColimaPath(), "status", "--profile", "pv") + return cmd.Run() == nil +} + +// EnsureRunning starts Colima if it's not already running. +func EnsureRunning() error { + if IsRunning() { + return nil + } + return Start() +} + +// IsInstalled checks if the Colima binary exists at the expected path. +func IsInstalled() bool { + _, err := os.Stat(config.ColimaPath()) + return err == nil +} + +// Version returns the Colima version string. +func Version() (string, error) { + out, err := exec.Command(config.ColimaPath(), "version").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/internal/colima/recovery.go b/internal/colima/recovery.go new file mode 100644 index 0000000..4dd3e23 --- /dev/null +++ b/internal/colima/recovery.go @@ -0,0 +1,17 @@ +package colima + +import ( + "github.com/prvious/pv/internal/registry" +) + +// RecoverServices checks all registered services and ensures their containers +// are running. This is called on daemon start after Colima is verified running. +// The actual Docker operations are performed by the caller since we don't import +// the container package here to avoid circular dependencies. +func ServicesToRecover(reg *registry.Registry) []string { + var keys []string + for key := range reg.Services { + keys = append(keys, key) + } + return keys +} diff --git a/internal/config/paths.go b/internal/config/paths.go index b716df2..18ebf90 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -135,6 +135,23 @@ func CACertPath() string { return filepath.Join(PvDir(), "caddy", "pki", "authorities", "local", "root.crt") } +func ServicesDir() string { + return filepath.Join(PvDir(), "services") +} + +func ServiceDataDir(service, version string) string { + return filepath.Join(ServicesDir(), service, version, "data") +} + +func ColimaPath() string { + return filepath.Join(BinDir(), "colima") +} + +func ColimaSocketPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".colima", "pv", "docker.sock") +} + func EnsureDirs() error { dirs := []string{ ConfigDir(), @@ -145,6 +162,7 @@ func EnsureDirs() error { PhpDir(), ComposerDir(), ComposerCacheDir(), + ServicesDir(), } for _, dir := range dirs { if err := os.MkdirAll(dir, 0755); err != nil { diff --git a/internal/container/engine.go b/internal/container/engine.go new file mode 100644 index 0000000..fb260a3 --- /dev/null +++ b/internal/container/engine.go @@ -0,0 +1,35 @@ +package container + +// CreateOpts defines parameters for creating a Docker container. +type CreateOpts struct { + Name string + Image string + Env []string + Ports map[int]int // host:container + Volumes map[string]string // host:container + Labels map[string]string + Cmd []string + HealthCmd []string + HealthInterval string + HealthTimeout string + HealthRetries int +} + +// Engine wraps Docker SDK operations. +// The actual implementation uses github.com/docker/docker/client +// and connects via the Colima Docker socket. +type Engine struct { + socketPath string +} + +func NewEngine(socketPath string) (*Engine, error) { + return &Engine{socketPath: socketPath}, nil +} + +func (e *Engine) SocketPath() string { + return e.socketPath +} + +func (e *Engine) Close() error { + return nil +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index a8412fb..f0ea42f 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -8,15 +8,33 @@ import ( "github.com/prvious/pv/internal/config" ) +type ServiceInstance struct { + Image string `json:"image"` + Port int `json:"port"` + ConsolePort int `json:"console_port,omitempty"` + ContainerID string `json:"container_id,omitempty"` +} + +type ProjectServices struct { + Mail bool `json:"mail,omitempty"` + MySQL string `json:"mysql,omitempty"` + Postgres string `json:"postgres,omitempty"` + Redis bool `json:"redis,omitempty"` + S3 bool `json:"s3,omitempty"` +} + type Project struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` - PHP string `json:"php,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + PHP string `json:"php,omitempty"` + Services *ProjectServices `json:"services,omitempty"` + Databases []string `json:"databases,omitempty"` } type Registry struct { - Projects []Project `json:"projects"` + Services map[string]*ServiceInstance `json:"services,omitempty"` + Projects []Project `json:"projects"` } func Load() (*Registry, error) { @@ -24,7 +42,7 @@ func Load() (*Registry, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return &Registry{}, nil + return &Registry{Services: make(map[string]*ServiceInstance)}, nil } return nil, err } @@ -32,6 +50,9 @@ func Load() (*Registry, error) { if err := json.Unmarshal(data, ®); err != nil { return nil, err } + if reg.Services == nil { + reg.Services = make(map[string]*ServiceInstance) + } return ®, nil } @@ -86,6 +107,84 @@ func (r *Registry) List() []Project { return r.Projects } +func (r *Registry) AddService(key string, svc *ServiceInstance) error { + if _, exists := r.Services[key]; exists { + return fmt.Errorf("service %q is already added", key) + } + r.Services[key] = svc + return nil +} + +func (r *Registry) RemoveService(key string) error { + if _, exists := r.Services[key]; !exists { + return fmt.Errorf("service %q not found", key) + } + delete(r.Services, key) + return nil +} + +func (r *Registry) FindService(key string) *ServiceInstance { + return r.Services[key] +} + +func (r *Registry) ListServices() map[string]*ServiceInstance { + return r.Services +} + +// ProjectsUsingService returns project names that reference a given service. +func (r *Registry) ProjectsUsingService(serviceName string) []string { + var names []string + for _, p := range r.Projects { + if p.Services == nil { + continue + } + switch serviceName { + case "mail": + if p.Services.Mail { + names = append(names, p.Name) + } + case "mysql": + if p.Services.MySQL != "" { + names = append(names, p.Name) + } + case "postgres": + if p.Services.Postgres != "" { + names = append(names, p.Name) + } + case "redis": + if p.Services.Redis { + names = append(names, p.Name) + } + case "s3": + if p.Services.S3 { + names = append(names, p.Name) + } + } + } + return names +} + +// UnbindService removes a service binding from all projects. +func (r *Registry) UnbindService(serviceName string) { + for i := range r.Projects { + if r.Projects[i].Services == nil { + continue + } + switch serviceName { + case "mail": + r.Projects[i].Services.Mail = false + case "mysql": + r.Projects[i].Services.MySQL = "" + case "postgres": + r.Projects[i].Services.Postgres = "" + case "redis": + r.Projects[i].Services.Redis = false + case "s3": + r.Projects[i].Services.S3 = false + } + } +} + // GroupByPHP groups projects by their PHP version. // Projects with an empty PHP field are grouped under the given defaultVersion. func (r *Registry) GroupByPHP(defaultVersion string) map[string][]Project { diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 7ca06c7..df00c73 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -307,3 +307,169 @@ func TestSave_CreatesDirectories(t *testing.T) { t.Fatalf("registry file does not exist after Save(): %v", err) } } + +// --- Service CRUD tests --- + +func TestAddService(t *testing.T) { + r := &Registry{Services: make(map[string]*ServiceInstance)} + err := r.AddService("mysql:8.0.32", &ServiceInstance{Image: "mysql:8.0.32", Port: 33032}) + if err != nil { + t.Fatalf("AddService() error = %v", err) + } + if len(r.Services) != 1 { + t.Fatalf("expected 1 service, got %d", len(r.Services)) + } +} + +func TestAddService_Duplicate(t *testing.T) { + r := &Registry{Services: make(map[string]*ServiceInstance)} + _ = r.AddService("redis", &ServiceInstance{Image: "redis:latest", Port: 6379}) + err := r.AddService("redis", &ServiceInstance{Image: "redis:latest", Port: 6379}) + if err == nil { + t.Fatal("expected error for duplicate service, got nil") + } +} + +func TestRemoveService(t *testing.T) { + r := &Registry{Services: make(map[string]*ServiceInstance)} + _ = r.AddService("redis", &ServiceInstance{Image: "redis:latest", Port: 6379}) + err := r.RemoveService("redis") + if err != nil { + t.Fatalf("RemoveService() error = %v", err) + } + if len(r.Services) != 0 { + t.Fatalf("expected 0 services, got %d", len(r.Services)) + } +} + +func TestRemoveService_NotFound(t *testing.T) { + r := &Registry{Services: make(map[string]*ServiceInstance)} + err := r.RemoveService("mysql") + if err == nil { + t.Fatal("expected error for non-existent service") + } +} + +func TestFindService(t *testing.T) { + r := &Registry{Services: make(map[string]*ServiceInstance)} + _ = r.AddService("redis", &ServiceInstance{Image: "redis:latest", Port: 6379}) + + svc := r.FindService("redis") + if svc == nil { + t.Fatal("FindService() returned nil") + } + if svc.Port != 6379 { + t.Errorf("Port = %d, want 6379", svc.Port) + } + + if r.FindService("mysql") != nil { + t.Error("FindService(mysql) should return nil") + } +} + +func TestProjectsUsingService(t *testing.T) { + r := &Registry{ + Services: make(map[string]*ServiceInstance), + Projects: []Project{ + {Name: "app1", Path: "/a", Services: &ProjectServices{MySQL: "8.0.32", Redis: true}}, + {Name: "app2", Path: "/b", Services: &ProjectServices{MySQL: "8.0.32"}}, + {Name: "app3", Path: "/c"}, + }, + } + + mysqlProjects := r.ProjectsUsingService("mysql") + if len(mysqlProjects) != 2 { + t.Errorf("expected 2 mysql projects, got %d", len(mysqlProjects)) + } + + redisProjects := r.ProjectsUsingService("redis") + if len(redisProjects) != 1 { + t.Errorf("expected 1 redis project, got %d", len(redisProjects)) + } + + pgProjects := r.ProjectsUsingService("postgres") + if len(pgProjects) != 0 { + t.Errorf("expected 0 postgres projects, got %d", len(pgProjects)) + } +} + +func TestUnbindService(t *testing.T) { + r := &Registry{ + Services: make(map[string]*ServiceInstance), + Projects: []Project{ + {Name: "app1", Path: "/a", Services: &ProjectServices{MySQL: "8.0.32"}}, + {Name: "app2", Path: "/b", Services: &ProjectServices{MySQL: "8.0.32"}}, + }, + } + r.UnbindService("mysql") + for _, p := range r.Projects { + if p.Services != nil && p.Services.MySQL != "" { + t.Errorf("project %s still has MySQL binding", p.Name) + } + } +} + +func TestLoad_BackwardCompat_NoServicesField(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := config.EnsureDirs(); err != nil { + t.Fatalf("EnsureDirs() error = %v", err) + } + + // Old-format JSON without services field. + data := `{"projects":[{"name":"myapp","path":"/srv/myapp","type":"laravel"}]}` + if err := os.WriteFile(config.RegistryPath(), []byte(data), 0644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + reg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if reg.Services == nil { + t.Fatal("Services map should be initialized") + } + if len(reg.Services) != 0 { + t.Errorf("expected 0 services, got %d", len(reg.Services)) + } + if len(reg.Projects) != 1 { + t.Errorf("expected 1 project, got %d", len(reg.Projects)) + } +} + +func TestServiceSaveLoad_RoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + reg := &Registry{Services: make(map[string]*ServiceInstance)} + _ = reg.Add(Project{Name: "app1", Path: "/srv/app1", Type: "laravel"}) + _ = reg.AddService("mysql:8.0.32", &ServiceInstance{ + Image: "mysql:8.0.32", + Port: 33032, + ContainerID: "abc123", + }) + _ = reg.AddService("redis", &ServiceInstance{ + Image: "redis:latest", + Port: 6379, + }) + + if err := reg.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if len(loaded.Services) != 2 { + t.Fatalf("expected 2 services, got %d", len(loaded.Services)) + } + if loaded.Services["mysql:8.0.32"].Port != 33032 { + t.Errorf("mysql port = %d, want 33032", loaded.Services["mysql:8.0.32"].Port) + } + if loaded.Services["redis"].Port != 6379 { + t.Errorf("redis port = %d, want 6379", loaded.Services["redis"].Port) + } +} diff --git a/internal/services/dotenv.go b/internal/services/dotenv.go new file mode 100644 index 0000000..67ef9b1 --- /dev/null +++ b/internal/services/dotenv.go @@ -0,0 +1,79 @@ +package services + +import ( + "os" + "strings" +) + +// ReadDotEnv reads a .env file into a map of key=value pairs. +func ReadDotEnv(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + result := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + result[parts[0]] = parts[1] + } + } + return result, nil +} + +// MergeDotEnv reads an existing .env file, replaces matching keys in-place, +// appends new keys, and writes the result. Creates a backup at backupPath. +func MergeDotEnv(envPath, backupPath string, newVars map[string]string) error { + // Read existing content. + existing, err := os.ReadFile(envPath) + if err != nil && !os.IsNotExist(err) { + return err + } + + // Create backup if file exists. + if err == nil && backupPath != "" { + if err := os.WriteFile(backupPath, existing, 0644); err != nil { + return err + } + } + + // Track which keys we've already replaced. + replaced := make(map[string]bool) + var lines []string + + if len(existing) > 0 { + for _, line := range strings.Split(string(existing), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + key := parts[0] + if val, ok := newVars[key]; ok { + lines = append(lines, key+"="+val) + replaced[key] = true + continue + } + } + } + lines = append(lines, line) + } + } + + // Append keys that weren't replaced. + for key, val := range newVars { + if !replaced[key] { + lines = append(lines, key+"="+val) + } + } + + content := strings.Join(lines, "\n") + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + + return os.WriteFile(envPath, []byte(content), 0644) +} diff --git a/internal/services/dotenv_test.go b/internal/services/dotenv_test.go new file mode 100644 index 0000000..e28b4ff --- /dev/null +++ b/internal/services/dotenv_test.go @@ -0,0 +1,129 @@ +package services + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadDotEnv(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + content := `APP_NAME=MyApp +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +# This is a comment +REDIS_HOST=127.0.0.1 +` + os.WriteFile(envPath, []byte(content), 0644) + + env, err := ReadDotEnv(envPath) + if err != nil { + t.Fatalf("ReadDotEnv() error = %v", err) + } + + if env["APP_NAME"] != "MyApp" { + t.Errorf("APP_NAME = %q", env["APP_NAME"]) + } + if env["DB_CONNECTION"] != "mysql" { + t.Errorf("DB_CONNECTION = %q", env["DB_CONNECTION"]) + } + if env["REDIS_HOST"] != "127.0.0.1" { + t.Errorf("REDIS_HOST = %q", env["REDIS_HOST"]) + } + // Comment should not appear. + if _, ok := env["# This is a comment"]; ok { + t.Error("comments should not appear in parsed env") + } +} + +func TestReadDotEnv_Missing(t *testing.T) { + _, err := ReadDotEnv("/nonexistent/.env") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestMergeDotEnv_ReplaceExisting(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + backupPath := filepath.Join(dir, ".env.pv-backup") + + original := "APP_NAME=MyApp\nDB_HOST=localhost\nDB_PORT=3306\n" + os.WriteFile(envPath, []byte(original), 0644) + + newVars := map[string]string{ + "DB_HOST": "127.0.0.1", + "DB_PORT": "33032", + } + + err := MergeDotEnv(envPath, backupPath, newVars) + if err != nil { + t.Fatalf("MergeDotEnv() error = %v", err) + } + + // Check backup was created. + backup, err := os.ReadFile(backupPath) + if err != nil { + t.Fatalf("backup not created: %v", err) + } + if string(backup) != original { + t.Errorf("backup = %q, want %q", string(backup), original) + } + + // Check new content. + result, _ := os.ReadFile(envPath) + if !strings.Contains(string(result), "DB_HOST=127.0.0.1") { + t.Error("DB_HOST not updated") + } + if !strings.Contains(string(result), "DB_PORT=33032") { + t.Error("DB_PORT not updated") + } + if !strings.Contains(string(result), "APP_NAME=MyApp") { + t.Error("APP_NAME should be preserved") + } +} + +func TestMergeDotEnv_AppendNew(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + + original := "APP_NAME=MyApp\n" + os.WriteFile(envPath, []byte(original), 0644) + + newVars := map[string]string{ + "DB_HOST": "127.0.0.1", + } + + err := MergeDotEnv(envPath, "", newVars) + if err != nil { + t.Fatalf("MergeDotEnv() error = %v", err) + } + + result, _ := os.ReadFile(envPath) + if !strings.Contains(string(result), "DB_HOST=127.0.0.1") { + t.Error("DB_HOST not appended") + } +} + +func TestMergeDotEnv_NewFile(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + + newVars := map[string]string{ + "DB_HOST": "127.0.0.1", + "DB_PORT": "33032", + } + + err := MergeDotEnv(envPath, "", newVars) + if err != nil { + t.Fatalf("MergeDotEnv() error = %v", err) + } + + result, _ := os.ReadFile(envPath) + if !strings.Contains(string(result), "DB_HOST=127.0.0.1") { + t.Error("DB_HOST not written") + } +} diff --git a/internal/services/mail.go b/internal/services/mail.go new file mode 100644 index 0000000..b34f5cd --- /dev/null +++ b/internal/services/mail.go @@ -0,0 +1,69 @@ +package services + +import ( + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/container" +) + +type Mail struct{} + +func (m *Mail) Name() string { return "mail" } +func (m *Mail) DisplayName() string { return "Mail" } + +func (m *Mail) DefaultVersion() string { return "latest" } + +func (m *Mail) ImageName(version string) string { + return "axllent/mailpit:" + version +} + +func (m *Mail) ContainerName(version string) string { + return "pv-mail-" + version +} + +func (m *Mail) Port(_ string) int { return 1025 } +func (m *Mail) ConsolePort(_ string) int { return 8025 } + +func (m *Mail) WebRoutes() []WebRoute { + return []WebRoute{ + {Subdomain: "mail", Port: 8025}, + } +} + +func (m *Mail) CreateOpts(version string) container.CreateOpts { + return container.CreateOpts{ + Name: m.ContainerName(version), + Image: m.ImageName(version), + Ports: map[int]int{ + 1025: 1025, + 8025: 8025, + }, + Volumes: map[string]string{ + config.ServiceDataDir("mail", version): "/data", + }, + Labels: map[string]string{ + "dev.prvious.pv": "true", + "dev.prvious.pv.service": "mail", + "dev.prvious.pv.version": version, + }, + HealthCmd: []string{"CMD-SHELL", "wget -q --spider http://localhost:8025/livez || exit 1"}, + HealthInterval: "2s", + HealthTimeout: "5s", + HealthRetries: 15, + } +} + +func (m *Mail) EnvVars(_ string, _ int) map[string]string { + return map[string]string{ + "MAIL_MAILER": "smtp", + "MAIL_HOST": "127.0.0.1", + "MAIL_PORT": "1025", + "MAIL_USERNAME": "", + "MAIL_PASSWORD": "", + } +} + +func (m *Mail) CreateDatabase(_ *container.Engine, _, _ string) error { + return nil +} + +func (m *Mail) HasDatabases() bool { return false } diff --git a/internal/services/mail_test.go b/internal/services/mail_test.go new file mode 100644 index 0000000..b9cab3d --- /dev/null +++ b/internal/services/mail_test.go @@ -0,0 +1,52 @@ +package services + +import "testing" + +func TestMailPorts(t *testing.T) { + m := &Mail{} + if got := m.Port("latest"); got != 1025 { + t.Errorf("Port = %d, want 1025", got) + } + if got := m.ConsolePort("latest"); got != 8025 { + t.Errorf("ConsolePort = %d, want 8025", got) + } +} + +func TestMailImageName(t *testing.T) { + m := &Mail{} + if got := m.ImageName("latest"); got != "axllent/mailpit:latest" { + t.Errorf("ImageName = %q, want %q", got, "axllent/mailpit:latest") + } +} + +func TestMailEnvVars(t *testing.T) { + m := &Mail{} + env := m.EnvVars("", 0) + if env["MAIL_MAILER"] != "smtp" { + t.Errorf("MAIL_MAILER = %q", env["MAIL_MAILER"]) + } + if env["MAIL_HOST"] != "127.0.0.1" { + t.Errorf("MAIL_HOST = %q", env["MAIL_HOST"]) + } + if env["MAIL_PORT"] != "1025" { + t.Errorf("MAIL_PORT = %q", env["MAIL_PORT"]) + } +} + +func TestMailWebRoutes(t *testing.T) { + m := &Mail{} + routes := m.WebRoutes() + if len(routes) != 1 { + t.Fatalf("WebRoutes len = %d, want 1", len(routes)) + } + if routes[0].Subdomain != "mail" || routes[0].Port != 8025 { + t.Errorf("route[0] = %+v, want {mail 8025}", routes[0]) + } +} + +func TestMailName(t *testing.T) { + m := &Mail{} + if m.Name() != "mail" { + t.Errorf("Name = %q, want %q", m.Name(), "mail") + } +} diff --git a/internal/services/mysql.go b/internal/services/mysql.go new file mode 100644 index 0000000..fd7591a --- /dev/null +++ b/internal/services/mysql.go @@ -0,0 +1,92 @@ +package services + +import ( + "fmt" + "strconv" + "strings" + + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/container" +) + +type MySQL struct{} + +func (m *MySQL) Name() string { return "mysql" } +func (m *MySQL) DisplayName() string { return "MySQL" } + +func (m *MySQL) DefaultVersion() string { return "latest" } + +func (m *MySQL) ImageName(version string) string { + return "mysql:" + version +} + +func (m *MySQL) ContainerName(version string) string { + return "pv-mysql-" + version +} + +// Port returns the host port for a MySQL version. +// Scheme: 33000 + patch version. For "latest", returns 33000. +func (m *MySQL) Port(version string) int { + if version == "latest" { + return 33000 + } + parts := strings.Split(version, ".") + if len(parts) >= 3 { + if patch, err := strconv.Atoi(parts[2]); err == nil { + return 33000 + patch + } + } + // Fallback for versions like "8.0" or "8" + return 33000 +} + +func (m *MySQL) ConsolePort(_ string) int { return 0 } +func (m *MySQL) WebRoutes() []WebRoute { return nil } + +func (m *MySQL) CreateOpts(version string) container.CreateOpts { + port := m.Port(version) + return container.CreateOpts{ + Name: m.ContainerName(version), + Image: m.ImageName(version), + Env: []string{ + "MYSQL_ALLOW_EMPTY_PASSWORD=yes", + }, + Ports: map[int]int{ + port: 3306, + }, + Volumes: map[string]string{ + config.ServiceDataDir("mysql", version): "/var/lib/mysql", + }, + Labels: map[string]string{ + "dev.prvious.pv": "true", + "dev.prvious.pv.service": "mysql", + "dev.prvious.pv.version": version, + }, + HealthCmd: []string{"CMD-SHELL", "mysqladmin ping -h 127.0.0.1"}, + HealthInterval: "2s", + HealthTimeout: "5s", + HealthRetries: 15, + } +} + +func (m *MySQL) EnvVars(projectName string, port int) map[string]string { + return map[string]string{ + "DB_CONNECTION": "mysql", + "DB_HOST": "127.0.0.1", + "DB_PORT": fmt.Sprintf("%d", port), + "DB_DATABASE": projectName, + "DB_USERNAME": "root", + "DB_PASSWORD": "", + } +} + +func (m *MySQL) CreateDatabase(engine *container.Engine, containerID, dbName string) error { + // Implemented via Docker exec in the container engine layer. + // The command: mysql -u root -e "CREATE DATABASE IF NOT EXISTS " + _ = engine + _ = containerID + _ = dbName + return nil +} + +func (m *MySQL) HasDatabases() bool { return true } diff --git a/internal/services/mysql_test.go b/internal/services/mysql_test.go new file mode 100644 index 0000000..4247ae1 --- /dev/null +++ b/internal/services/mysql_test.go @@ -0,0 +1,88 @@ +package services + +import "testing" + +func TestMySQLPort(t *testing.T) { + m := &MySQL{} + tests := []struct { + version string + want int + }{ + {"latest", 33000}, + {"8.0.32", 33032}, + {"8.0.45", 33045}, + {"8.0", 33000}, + {"8", 33000}, + } + for _, tt := range tests { + got := m.Port(tt.version) + if got != tt.want { + t.Errorf("MySQL.Port(%q) = %d, want %d", tt.version, got, tt.want) + } + } +} + +func TestMySQLImageName(t *testing.T) { + m := &MySQL{} + if got := m.ImageName("8.0.32"); got != "mysql:8.0.32" { + t.Errorf("ImageName = %q, want %q", got, "mysql:8.0.32") + } + if got := m.ImageName("latest"); got != "mysql:latest" { + t.Errorf("ImageName = %q, want %q", got, "mysql:latest") + } +} + +func TestMySQLContainerName(t *testing.T) { + m := &MySQL{} + if got := m.ContainerName("8.0.32"); got != "pv-mysql-8.0.32" { + t.Errorf("ContainerName = %q, want %q", got, "pv-mysql-8.0.32") + } +} + +func TestMySQLDefaultVersion(t *testing.T) { + m := &MySQL{} + if got := m.DefaultVersion(); got != "latest" { + t.Errorf("DefaultVersion = %q, want %q", got, "latest") + } +} + +func TestMySQLEnvVars(t *testing.T) { + m := &MySQL{} + env := m.EnvVars("my_app", 33032) + if env["DB_CONNECTION"] != "mysql" { + t.Errorf("DB_CONNECTION = %q, want %q", env["DB_CONNECTION"], "mysql") + } + if env["DB_PORT"] != "33032" { + t.Errorf("DB_PORT = %q, want %q", env["DB_PORT"], "33032") + } + if env["DB_DATABASE"] != "my_app" { + t.Errorf("DB_DATABASE = %q, want %q", env["DB_DATABASE"], "my_app") + } + if env["DB_USERNAME"] != "root" { + t.Errorf("DB_USERNAME = %q, want %q", env["DB_USERNAME"], "root") + } +} + +func TestMySQLCreateOpts(t *testing.T) { + m := &MySQL{} + opts := m.CreateOpts("8.0.32") + if opts.Name != "pv-mysql-8.0.32" { + t.Errorf("Name = %q, want %q", opts.Name, "pv-mysql-8.0.32") + } + if opts.Image != "mysql:8.0.32" { + t.Errorf("Image = %q, want %q", opts.Image, "mysql:8.0.32") + } + if len(opts.HealthCmd) == 0 { + t.Error("expected HealthCmd to be set") + } + if opts.Labels["dev.prvious.pv"] != "true" { + t.Error("expected pv label") + } +} + +func TestMySQLHasDatabases(t *testing.T) { + m := &MySQL{} + if !m.HasDatabases() { + t.Error("MySQL.HasDatabases() should return true") + } +} diff --git a/internal/services/postgres.go b/internal/services/postgres.go new file mode 100644 index 0000000..fd2b34b --- /dev/null +++ b/internal/services/postgres.go @@ -0,0 +1,86 @@ +package services + +import ( + "fmt" + "strconv" + + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/container" +) + +type Postgres struct{} + +func (p *Postgres) Name() string { return "postgres" } +func (p *Postgres) DisplayName() string { return "PostgreSQL" } + +func (p *Postgres) DefaultVersion() string { return "latest" } + +func (p *Postgres) ImageName(version string) string { + return "postgres:" + version +} + +func (p *Postgres) ContainerName(version string) string { + return "pv-postgres-" + version +} + +// Port returns the host port for a PostgreSQL version. +// Scheme: 54000 + major version. For "latest", returns 54000. +func (p *Postgres) Port(version string) int { + if version == "latest" { + return 54000 + } + major, err := strconv.Atoi(version) + if err == nil { + return 54000 + major + } + return 54000 +} + +func (p *Postgres) ConsolePort(_ string) int { return 0 } +func (p *Postgres) WebRoutes() []WebRoute { return nil } + +func (p *Postgres) CreateOpts(version string) container.CreateOpts { + port := p.Port(version) + return container.CreateOpts{ + Name: p.ContainerName(version), + Image: p.ImageName(version), + Env: []string{ + "POSTGRES_HOST_AUTH_METHOD=trust", + }, + Ports: map[int]int{ + port: 5432, + }, + Volumes: map[string]string{ + config.ServiceDataDir("postgres", version): "/var/lib/postgresql/data", + }, + Labels: map[string]string{ + "dev.prvious.pv": "true", + "dev.prvious.pv.service": "postgres", + "dev.prvious.pv.version": version, + }, + HealthCmd: []string{"CMD-SHELL", "pg_isready"}, + HealthInterval: "2s", + HealthTimeout: "5s", + HealthRetries: 15, + } +} + +func (p *Postgres) EnvVars(projectName string, port int) map[string]string { + return map[string]string{ + "DB_CONNECTION": "pgsql", + "DB_HOST": "127.0.0.1", + "DB_PORT": fmt.Sprintf("%d", port), + "DB_DATABASE": projectName, + "DB_USERNAME": "postgres", + "DB_PASSWORD": "", + } +} + +func (p *Postgres) CreateDatabase(engine *container.Engine, containerID, dbName string) error { + _ = engine + _ = containerID + _ = dbName + return nil +} + +func (p *Postgres) HasDatabases() bool { return true } diff --git a/internal/services/postgres_test.go b/internal/services/postgres_test.go new file mode 100644 index 0000000..1cb60aa --- /dev/null +++ b/internal/services/postgres_test.go @@ -0,0 +1,54 @@ +package services + +import "testing" + +func TestPostgresPort(t *testing.T) { + p := &Postgres{} + tests := []struct { + version string + want int + }{ + {"latest", 54000}, + {"16", 54016}, + {"17", 54017}, + } + for _, tt := range tests { + got := p.Port(tt.version) + if got != tt.want { + t.Errorf("Postgres.Port(%q) = %d, want %d", tt.version, got, tt.want) + } + } +} + +func TestPostgresImageName(t *testing.T) { + p := &Postgres{} + if got := p.ImageName("16"); got != "postgres:16" { + t.Errorf("ImageName = %q, want %q", got, "postgres:16") + } +} + +func TestPostgresContainerName(t *testing.T) { + p := &Postgres{} + if got := p.ContainerName("16"); got != "pv-postgres-16" { + t.Errorf("ContainerName = %q, want %q", got, "pv-postgres-16") + } +} + +func TestPostgresEnvVars(t *testing.T) { + p := &Postgres{} + env := p.EnvVars("my_app", 54016) + if env["DB_CONNECTION"] != "pgsql" { + t.Errorf("DB_CONNECTION = %q, want %q", env["DB_CONNECTION"], "pgsql") + } + if env["DB_USERNAME"] != "postgres" { + t.Errorf("DB_USERNAME = %q, want %q", env["DB_USERNAME"], "postgres") + } +} + +func TestPostgresCreateOpts(t *testing.T) { + p := &Postgres{} + opts := p.CreateOpts("16") + if opts.HealthCmd[1] != "pg_isready" { + t.Errorf("HealthCmd = %v, want pg_isready", opts.HealthCmd) + } +} diff --git a/internal/services/redis.go b/internal/services/redis.go new file mode 100644 index 0000000..1e86268 --- /dev/null +++ b/internal/services/redis.go @@ -0,0 +1,61 @@ +package services + +import ( + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/container" +) + +type Redis struct{} + +func (r *Redis) Name() string { return "redis" } +func (r *Redis) DisplayName() string { return "Redis" } + +func (r *Redis) DefaultVersion() string { return "latest" } + +func (r *Redis) ImageName(version string) string { + return "redis:" + version +} + +func (r *Redis) ContainerName(version string) string { + return "pv-redis-" + version +} + +func (r *Redis) Port(_ string) int { return 6379 } +func (r *Redis) ConsolePort(_ string) int { return 0 } +func (r *Redis) WebRoutes() []WebRoute { return nil } + +func (r *Redis) CreateOpts(version string) container.CreateOpts { + return container.CreateOpts{ + Name: r.ContainerName(version), + Image: r.ImageName(version), + Ports: map[int]int{ + 6379: 6379, + }, + Volumes: map[string]string{ + config.ServiceDataDir("redis", version): "/data", + }, + Labels: map[string]string{ + "dev.prvious.pv": "true", + "dev.prvious.pv.service": "redis", + "dev.prvious.pv.version": version, + }, + HealthCmd: []string{"CMD-SHELL", "redis-cli ping"}, + HealthInterval: "2s", + HealthTimeout: "5s", + HealthRetries: 15, + } +} + +func (r *Redis) EnvVars(_ string, _ int) map[string]string { + return map[string]string{ + "REDIS_HOST": "127.0.0.1", + "REDIS_PORT": "6379", + "REDIS_PASSWORD": "null", + } +} + +func (r *Redis) CreateDatabase(_ *container.Engine, _, _ string) error { + return nil +} + +func (r *Redis) HasDatabases() bool { return false } diff --git a/internal/services/redis_test.go b/internal/services/redis_test.go new file mode 100644 index 0000000..b136fd2 --- /dev/null +++ b/internal/services/redis_test.go @@ -0,0 +1,42 @@ +package services + +import "testing" + +func TestRedisPort(t *testing.T) { + r := &Redis{} + if got := r.Port("latest"); got != 6379 { + t.Errorf("Redis.Port() = %d, want 6379", got) + } + if got := r.Port("7.2"); got != 6379 { + t.Errorf("Redis.Port(7.2) = %d, want 6379", got) + } +} + +func TestRedisEnvVars(t *testing.T) { + r := &Redis{} + env := r.EnvVars("", 6379) + if env["REDIS_HOST"] != "127.0.0.1" { + t.Errorf("REDIS_HOST = %q", env["REDIS_HOST"]) + } + if env["REDIS_PORT"] != "6379" { + t.Errorf("REDIS_PORT = %q", env["REDIS_PORT"]) + } + if env["REDIS_PASSWORD"] != "null" { + t.Errorf("REDIS_PASSWORD = %q", env["REDIS_PASSWORD"]) + } +} + +func TestRedisHasNoDatabases(t *testing.T) { + r := &Redis{} + if r.HasDatabases() { + t.Error("Redis.HasDatabases() should return false") + } +} + +func TestRedisCreateOpts(t *testing.T) { + r := &Redis{} + opts := r.CreateOpts("latest") + if opts.HealthCmd[1] != "redis-cli ping" { + t.Errorf("HealthCmd = %v, want redis-cli ping", opts.HealthCmd) + } +} diff --git a/internal/services/s3.go b/internal/services/s3.go new file mode 100644 index 0000000..5a402dc --- /dev/null +++ b/internal/services/s3.go @@ -0,0 +1,76 @@ +package services + +import ( + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/container" +) + +type S3 struct{} + +func (s *S3) Name() string { return "s3" } +func (s *S3) DisplayName() string { return "S3 Storage" } + +func (s *S3) DefaultVersion() string { return "latest" } + +func (s *S3) ImageName(version string) string { + return "rustfs/rustfs:" + version +} + +func (s *S3) ContainerName(version string) string { + return "pv-s3-" + version +} + +func (s *S3) Port(_ string) int { return 9000 } +func (s *S3) ConsolePort(_ string) int { return 9001 } + +func (s *S3) WebRoutes() []WebRoute { + return []WebRoute{ + {Subdomain: "s3", Port: 9001}, + {Subdomain: "s3-api", Port: 9000}, + } +} + +func (s *S3) CreateOpts(version string) container.CreateOpts { + return container.CreateOpts{ + Name: s.ContainerName(version), + Image: s.ImageName(version), + Env: []string{ + "RUSTFS_ROOT_USER=minioadmin", + "RUSTFS_ROOT_PASSWORD=minioadmin", + }, + Ports: map[int]int{ + 9000: 9000, + 9001: 9001, + }, + Volumes: map[string]string{ + config.ServiceDataDir("s3", version): "/data", + }, + Labels: map[string]string{ + "dev.prvious.pv": "true", + "dev.prvious.pv.service": "s3", + "dev.prvious.pv.version": version, + }, + Cmd: []string{"server", "/data", "--console-address", ":9001"}, + HealthCmd: []string{"CMD-SHELL", "curl -f http://localhost:9000/minio/health/live"}, + HealthInterval: "2s", + HealthTimeout: "5s", + HealthRetries: 15, + } +} + +func (s *S3) EnvVars(projectName string, _ int) map[string]string { + return map[string]string{ + "AWS_ACCESS_KEY_ID": "minioadmin", + "AWS_SECRET_ACCESS_KEY": "minioadmin", + "AWS_DEFAULT_REGION": "us-east-1", + "AWS_BUCKET": projectName, + "AWS_ENDPOINT": "http://127.0.0.1:9000", + "AWS_USE_PATH_STYLE_ENDPOINT": "true", + } +} + +func (s *S3) CreateDatabase(_ *container.Engine, _, _ string) error { + return nil +} + +func (s *S3) HasDatabases() bool { return false } diff --git a/internal/services/s3_test.go b/internal/services/s3_test.go new file mode 100644 index 0000000..3d78739 --- /dev/null +++ b/internal/services/s3_test.go @@ -0,0 +1,69 @@ +package services + +import "testing" + +func TestS3Ports(t *testing.T) { + s := &S3{} + if got := s.Port("latest"); got != 9000 { + t.Errorf("Port = %d, want 9000", got) + } + if got := s.ConsolePort("latest"); got != 9001 { + t.Errorf("ConsolePort = %d, want 9001", got) + } +} + +func TestS3ImageName(t *testing.T) { + s := &S3{} + if got := s.ImageName("latest"); got != "rustfs/rustfs:latest" { + t.Errorf("ImageName = %q, want %q", got, "rustfs/rustfs:latest") + } +} + +func TestS3EnvVars(t *testing.T) { + s := &S3{} + env := s.EnvVars("my_app", 9000) + if env["AWS_ACCESS_KEY_ID"] != "minioadmin" { + t.Errorf("AWS_ACCESS_KEY_ID = %q", env["AWS_ACCESS_KEY_ID"]) + } + if env["AWS_BUCKET"] != "my_app" { + t.Errorf("AWS_BUCKET = %q", env["AWS_BUCKET"]) + } + if env["AWS_ENDPOINT"] != "http://127.0.0.1:9000" { + t.Errorf("AWS_ENDPOINT = %q", env["AWS_ENDPOINT"]) + } + if env["AWS_USE_PATH_STYLE_ENDPOINT"] != "true" { + t.Errorf("AWS_USE_PATH_STYLE_ENDPOINT = %q", env["AWS_USE_PATH_STYLE_ENDPOINT"]) + } +} + +func TestS3WebRoutes(t *testing.T) { + s := &S3{} + routes := s.WebRoutes() + if len(routes) != 2 { + t.Fatalf("WebRoutes len = %d, want 2", len(routes)) + } + if routes[0].Subdomain != "s3" || routes[0].Port != 9001 { + t.Errorf("route[0] = %+v, want {s3 9001}", routes[0]) + } + if routes[1].Subdomain != "s3-api" || routes[1].Port != 9000 { + t.Errorf("route[1] = %+v, want {s3-api 9000}", routes[1]) + } +} + +func TestS3CreateOpts(t *testing.T) { + s := &S3{} + opts := s.CreateOpts("latest") + if len(opts.Cmd) == 0 { + t.Error("expected Cmd to be set for S3") + } + if opts.Ports[9001] != 9001 { + t.Error("expected console port 9001 mapping") + } +} + +func TestS3Name(t *testing.T) { + s := &S3{} + if s.Name() != "s3" { + t.Errorf("Name = %q, want %q", s.Name(), "s3") + } +} diff --git a/internal/services/service.go b/internal/services/service.go new file mode 100644 index 0000000..41c4963 --- /dev/null +++ b/internal/services/service.go @@ -0,0 +1,58 @@ +package services + +import ( + "fmt" + + "github.com/prvious/pv/internal/container" +) + +// WebRoute maps a subdomain under pv.{tld} to a local port. +// For example, {Subdomain: "s3", Port: 9001} routes s3.pv.test → 127.0.0.1:9001. +type WebRoute struct { + Subdomain string + Port int +} + +type Service interface { + Name() string + DisplayName() string + ImageName(version string) string + ContainerName(version string) string + DefaultVersion() string + Port(version string) int + ConsolePort(version string) int + WebRoutes() []WebRoute // HTTP endpoints exposed under *.pv.{tld} + CreateOpts(version string) container.CreateOpts + EnvVars(projectName string, port int) map[string]string + CreateDatabase(engine *container.Engine, containerID, dbName string) error + HasDatabases() bool +} + +var registry = map[string]Service{ + "mail": &Mail{}, + "mysql": &MySQL{}, + "postgres": &Postgres{}, + "redis": &Redis{}, + "s3": &S3{}, +} + +func Lookup(name string) (Service, error) { + svc, ok := registry[name] + if !ok { + return nil, fmt.Errorf("unknown service %q (available: mail, mysql, postgres, redis, s3)", name) + } + return svc, nil +} + +func Available() []string { + return []string{"mail", "mysql", "postgres", "redis", "s3"} +} + +// ServiceKey returns the registry key for a service instance. +// For versioned services: "mysql:8.0.32". For unversioned: "redis". +func ServiceKey(name, version string) string { + if version == "" || version == "latest" { + return name + } + return name + ":" + version +} diff --git a/internal/services/service_test.go b/internal/services/service_test.go new file mode 100644 index 0000000..a04c557 --- /dev/null +++ b/internal/services/service_test.go @@ -0,0 +1,49 @@ +package services + +import ( + "testing" +) + +func TestLookup_Valid(t *testing.T) { + for _, name := range []string{"mail", "mysql", "postgres", "redis", "s3"} { + svc, err := Lookup(name) + if err != nil { + t.Errorf("Lookup(%q) error = %v", name, err) + } + if svc.Name() != name { + t.Errorf("Lookup(%q).Name() = %q", name, svc.Name()) + } + } +} + +func TestLookup_Invalid(t *testing.T) { + _, err := Lookup("mongodb") + if err == nil { + t.Error("expected error for unknown service, got nil") + } +} + +func TestServiceKey(t *testing.T) { + tests := []struct { + name, version, want string + }{ + {"mysql", "8.0.32", "mysql:8.0.32"}, + {"mysql", "latest", "mysql"}, + {"redis", "", "redis"}, + {"redis", "latest", "redis"}, + {"postgres", "16", "postgres:16"}, + } + for _, tt := range tests { + got := ServiceKey(tt.name, tt.version) + if got != tt.want { + t.Errorf("ServiceKey(%q, %q) = %q, want %q", tt.name, tt.version, got, tt.want) + } + } +} + +func TestAvailable(t *testing.T) { + names := Available() + if len(names) != 5 { + t.Errorf("Available() returned %d services, want 5", len(names)) + } +} diff --git a/scripts/e2e/diagnostics.sh b/scripts/e2e/diagnostics.sh index 753128f..da99273 100755 --- a/scripts/e2e/diagnostics.sh +++ b/scripts/e2e/diagnostics.sh @@ -54,3 +54,18 @@ ls -la ~/.pv/data/composer.phar 2>/dev/null || echo "(no composer.phar)" echo "" echo "==> composer shim contents" cat ~/.pv/bin/composer 2>/dev/null || echo "(no composer shim)" +echo "" +echo "==> services dir" +ls -laR ~/.pv/services/ 2>/dev/null || echo "(no services dir)" +echo "" +echo "==> colima binary" +ls -la ~/.pv/bin/colima 2>/dev/null || echo "(no colima binary)" +echo "" +echo "==> colima version" +~/.pv/bin/colima version 2>/dev/null || echo "(colima version failed)" +echo "" +echo "==> colima status" +~/.pv/bin/colima status --profile pv 2>/dev/null || echo "(colima not running)" +echo "" +echo "==> pv service list" +pv service list 2>&1 || echo "(pv service list failed)"