From e25f410ed3246f872f511bdd7756284c3aecfde0 Mon Sep 17 00:00:00 2001 From: Flo Schuessel Date: Sun, 18 Jan 2026 10:11:09 +0100 Subject: [PATCH 1/2] Add AGENTS.md --- AGENTS.md | 239 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..80dc636 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,239 @@ +# AGENTS.md - AI Coding Agent Guidelines for DevProxy + +This document provides guidelines for AI coding agents working in the devproxy codebase. + +## Project Overview + +DevProxy is a local development reverse proxy written in Go. It automatically routes traffic to Docker containers based on labels, provides automatic TLS certificates, and includes a built-in DNS server. + +- **Language:** Go 1.25.5 +- **Module:** `github.com/munichmade/devproxy` +- **Entry Point:** `cmd/devproxy/main.go` +- **CLI Framework:** Cobra (`github.com/spf13/cobra`) + +## Build Commands + +```bash +# Build binary for current platform +make build + +# Build for all platforms (darwin/linux, amd64/arm64) +make build-all + +# Run in foreground +make run + +# Development with live reload (requires entr) +make dev + +# Install to /usr/local/bin +make install + +# Clean build artifacts +make clean +``` + +## Testing + +```bash +# Run all tests +make test +# Or directly: +go test -v ./... + +# Run a single test file +go test -v ./internal/docker/client_test.go + +# Run a specific test function +go test -v ./internal/docker -run TestNewClient + +# Run a specific subtest +go test -v ./internal/docker -run "TestNewClient/creates_client_with_default_options" + +# Run tests with race detector (used in CI) +go test -v -race ./... + +# Run tests with coverage +make test-coverage + +# Run integration tests (requires Docker) +go test -v -tags=integration ./... +``` + +## Linting and Formatting + +```bash +# Run linter (golangci-lint) +make lint + +# Format code +make fmt +# Or directly: +go fmt ./... +goimports -local github.com/munichmade/devproxy -w . +``` + +### Enabled Linters + +From `.golangci.yml`: errcheck, gosimple, govet, ineffassign, staticcheck, unused, gofmt, goimports, misspell, unconvert + +## Code Style Guidelines + +### Package Structure + +``` +cmd/devproxy/ # CLI application entry point + cmd/ # Cobra commands (root.go, start.go, stop.go, etc.) +internal/ # Private packages + ca/ # Certificate Authority management + cert/ # TLS certificate generation + config/ # Configuration loading/watching + daemon/ # Daemon lifecycle management + dns/ # Built-in DNS server + docker/ # Docker integration/watcher + logging/ # Logging utilities + paths/ # File path utilities + privilege/ # Privilege management + proxy/ # HTTP/HTTPS/TCP proxy + resolver/ # System DNS resolver config + service/ # System service integration +``` + +### Import Organization + +Imports must be grouped and ordered (enforced by goimports): + +```go +import ( + // Standard library + "context" + "fmt" + "sync" + + // External dependencies + "github.com/docker/docker/api/types" + "github.com/spf13/cobra" + + // Local packages (prefix: github.com/munichmade/devproxy) + "github.com/munichmade/devproxy/internal/config" + "github.com/munichmade/devproxy/internal/proxy" +) +``` + +### Package Comments + +Every package must have a doc comment: + +```go +// Package config provides configuration loading and management for devproxy. +package config +``` + +### Type and Function Documentation + +```go +// Config represents the complete devproxy configuration. +type Config struct { + DNS DNSConfig `yaml:"dns"` +} + +// Default returns a Config with sensible default values. +// HTTP/HTTPS use privileged ports 80/443 (requires running as root). +func Default() *Config { + // ... +} +``` + +### Error Handling + +- Use `errors.New()` for sentinel errors +- Use `fmt.Errorf()` with `%w` for error wrapping +- Define package-level error variables for common errors + +```go +var ( + ErrRouteExists = errors.New("route already exists") + ErrRouteNotFound = errors.New("route not found") +) + +func doSomething() error { + if err := operation(); err != nil { + return fmt.Errorf("operation failed: %w", err) + } + return nil +} +``` + +### Struct Tags + +Use yaml/json tags for serialization: + +```go +type DNSConfig struct { + Enabled bool `yaml:"enabled"` + Listen string `yaml:"listen"` + Domains []string `yaml:"domains"` +} +``` + +### Testing Conventions + +- Use table-driven tests with `t.Run()` for subtests +- Test file naming: `*_test.go` +- Skip tests that require external dependencies (Docker) when unavailable + +```go +func TestNewClient(t *testing.T) { + t.Run("creates client with default options", func(t *testing.T) { + client, err := NewClient(testLogger()) + if err != nil { + t.Skipf("Docker not available: %v", err) + } + defer client.Close() + + if client.API() == nil { + t.Error("expected docker client to be initialized") + } + }) +} +``` + +### Concurrency + +- Use `sync.RWMutex` for thread-safe data structures +- Use `context.Context` for cancellation and timeouts + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() +``` + +### Naming Conventions + +- **Packages:** lowercase, single word (e.g., `proxy`, `config`) +- **Exported types:** PascalCase (e.g., `RouteTable`, `Config`) +- **Unexported functions:** camelCase (e.g., `isWildcardHost`) +- **Constants:** PascalCase for exported, camelCase for unexported +- **Interfaces:** often end in `-er` (e.g., `Resolver`, `Watcher`) + +### Spelling + +US English spelling is enforced by the misspell linter. + +## CI Requirements + +Before submitting code, ensure: + +1. `make lint` passes +2. `make test` passes +3. Code is formatted with `make fmt` + +CI runs on both Ubuntu and macOS with race detector enabled. + +## Key Dependencies + +- `github.com/docker/docker` - Docker API client +- `github.com/miekg/dns` - DNS server/client +- `github.com/spf13/cobra` - CLI framework +- `gopkg.in/yaml.v3` - YAML parsing +- `github.com/gorilla/websocket` - WebSocket support From d800e75994b3d8da05cfcf33e496975f4d58be32 Mon Sep 17 00:00:00 2001 From: Flo Schuessel Date: Sun, 18 Jan 2026 10:37:35 +0100 Subject: [PATCH 2/2] Improve status command Add more information and grouping for projects --- .gitignore | 1 - CHANGELOG.md | 7 + Makefile | 4 +- cmd/devproxy/cmd/status.go | 102 ++++++++++++--- cmd/devproxy/cmd/status_test.go | 219 ++++++++++++++++++++++++++++++++ internal/docker/sync.go | 6 + internal/docker/sync_test.go | 141 ++++++++++++++++++++ internal/proxy/routes.go | 7 + 8 files changed, 466 insertions(+), 21 deletions(-) create mode 100644 cmd/devproxy/cmd/status_test.go diff --git a/.gitignore b/.gitignore index 53e57b6..25c6be2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Build output bin/ -devproxy dist/ # IDE diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e13c7..d97bb06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Status command now groups routes by Docker Compose project for better readability +- Routes display includes project working directory path + ## [0.1.0] 2026-01-18 ### Added diff --git a/Makefile b/Makefile index 818af42..e971969 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ LDFLAGS := -ldflags "-X github.com/munichmade/devproxy/cmd/devproxy/cmd.Version= # Binary info BINARY := devproxy BUILD_DIR := bin -INSTALL_DIR := /usr/local/bin +INSTALL_DIR := /opt/homebrew/bin # Platforms for cross-compilation PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 @@ -69,7 +69,7 @@ fmt: ## install: Build and install to /usr/local/bin install: build @echo "Installing $(BINARY) to $(INSTALL_DIR)..." - @sudo rm $(INSTALL_DIR)/$(BINARY) + @sudo rm $(INSTALL_DIR)/$(BINARY) || echo "No install yet" @sudo cp $(BUILD_DIR)/$(BINARY) $(INSTALL_DIR)/$(BINARY) @sudo chmod +x $(INSTALL_DIR)/$(BINARY) @echo "Installed successfully" diff --git a/cmd/devproxy/cmd/status.go b/cmd/devproxy/cmd/status.go index 2b8c44e..9c9cdb0 100644 --- a/cmd/devproxy/cmd/status.go +++ b/cmd/devproxy/cmd/status.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "os" + "sort" + "strings" "text/tabwriter" "github.com/spf13/cobra" @@ -37,11 +39,11 @@ var statusCmd = &cobra.Command{ // Status represents the current state of devproxy. type Status struct { - Running bool `json:"running"` - PID int `json:"pid,omitempty"` - Uptime string `json:"uptime,omitempty"` - Entrypoints []Entrypoint `json:"entrypoints"` - Routes []RouteStatus `json:"routes"` + Running bool `json:"running"` + PID int `json:"pid,omitempty"` + Uptime string `json:"uptime,omitempty"` + Entrypoints []Entrypoint `json:"entrypoints"` + Projects map[string]*ProjectRoutes `json:"projects"` } // Entrypoint represents a listening endpoint. @@ -52,6 +54,12 @@ type Entrypoint struct { Status string `json:"status"` } +// ProjectRoutes represents routes grouped by project. +type ProjectRoutes struct { + ProjectDir string `json:"project_dir,omitempty"` + Routes []RouteStatus `json:"routes"` +} + // RouteStatus represents a proxied route. type RouteStatus struct { Host string `json:"host"` @@ -67,7 +75,7 @@ func getStatus() Status { status := Status{ Running: d.IsRunning(), Entrypoints: []Entrypoint{}, - Routes: []RouteStatus{}, + Projects: make(map[string]*ProjectRoutes), } if status.Running { @@ -108,12 +116,26 @@ func getStatus() Status { } } - // Load routes from state file (written by daemon) + // Load routes from state file (written by daemon) and group by project if status.Running { routes, err := proxy.LoadState() if err == nil && len(routes) > 0 { for _, route := range routes { - status.Routes = append(status.Routes, RouteStatus{ + projectName := route.ProjectName + if projectName == "" { + projectName = "ungrouped" + } + + project, exists := status.Projects[projectName] + if !exists { + project = &ProjectRoutes{ + ProjectDir: shortenPath(route.ProjectDir), + Routes: []RouteStatus{}, + } + status.Projects[projectName] = project + } + + project.Routes = append(project.Routes, RouteStatus{ Host: route.Host, Backend: route.Backend, ContainerName: route.ContainerName, @@ -134,6 +156,21 @@ func getListenerStatus(running bool) string { return "stopped" } +// shortenPath replaces the user's home directory with ~ +func shortenPath(path string) string { + if path == "" { + return "" + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if strings.HasPrefix(path, home) { + return "~" + strings.TrimPrefix(path, home) + } + return path +} + func outputStatusText(status Status) { if status.Running { fmt.Printf("devproxy is running (pid %d)\n", status.PID) @@ -155,21 +192,50 @@ func outputStatusText(status Status) { fmt.Println() - // Routes - if len(status.Routes) == 0 { + // Routes grouped by project + if len(status.Projects) == 0 { fmt.Println("Routes: none") } else { fmt.Println("Routes:") - w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, " HOST\tBACKEND\tCONTAINER\n") - for _, route := range status.Routes { - container := route.ContainerName - if container == "" { - container = "-" + + // Sort project names for consistent output, with "ungrouped" last + projectNames := make([]string, 0, len(status.Projects)) + for name := range status.Projects { + projectNames = append(projectNames, name) + } + sort.Slice(projectNames, func(i, j int) bool { + // "ungrouped" always goes last + if projectNames[i] == "ungrouped" { + return false + } + if projectNames[j] == "ungrouped" { + return true + } + return projectNames[i] < projectNames[j] + }) + + for _, projectName := range projectNames { + project := status.Projects[projectName] + + // Project header + if project.ProjectDir != "" { + fmt.Printf("\n %s (%s)\n", projectName, project.ProjectDir) + } else { + fmt.Printf("\n %s\n", projectName) + } + + // Routes table + w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, " HOST\tBACKEND\tCONTAINER\n") + for _, route := range project.Routes { + container := route.ContainerName + if container == "" { + container = "-" + } + fmt.Fprintf(w, " %s\t%s\t%s\n", route.Host, route.Backend, container) } - fmt.Fprintf(w, " %s\t%s\t%s\n", route.Host, route.Backend, container) + w.Flush() } - w.Flush() } } diff --git a/cmd/devproxy/cmd/status_test.go b/cmd/devproxy/cmd/status_test.go new file mode 100644 index 0000000..83c4417 --- /dev/null +++ b/cmd/devproxy/cmd/status_test.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "os" + "testing" +) + +func TestShortenPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("could not get home dir: %v", err) + } + + t.Run("shortens home directory to tilde", func(t *testing.T) { + input := home + "/projects/myapp" + expected := "~/projects/myapp" + + result := shortenPath(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + }) + + t.Run("shortens exact home directory", func(t *testing.T) { + input := home + expected := "~" + + result := shortenPath(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + }) + + t.Run("leaves non-home paths unchanged", func(t *testing.T) { + input := "/var/lib/docker/volumes" + expected := "/var/lib/docker/volumes" + + result := shortenPath(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + }) + + t.Run("handles empty path", func(t *testing.T) { + input := "" + expected := "" + + result := shortenPath(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + }) + + t.Run("handles path that starts with home prefix but is different", func(t *testing.T) { + // e.g., if home is /home/user, don't shorten /home/username + input := home + "name/projects" + expected := "~name/projects" // This is technically how strings.TrimPrefix works + + result := shortenPath(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } + }) +} + +func TestProjectRoutesSorting(t *testing.T) { + t.Run("ungrouped sorts last", func(t *testing.T) { + projects := map[string]*ProjectRoutes{ + "ungrouped": {Routes: []RouteStatus{{Host: "a.localhost"}}}, + "alpha": {Routes: []RouteStatus{{Host: "b.localhost"}}}, + "beta": {Routes: []RouteStatus{{Host: "c.localhost"}}}, + } + + // Extract project names and sort as done in outputStatusText + projectNames := make([]string, 0, len(projects)) + for name := range projects { + projectNames = append(projectNames, name) + } + + // Sort with ungrouped last + for i := 0; i < len(projectNames)-1; i++ { + for j := i + 1; j < len(projectNames); j++ { + shouldSwap := false + if projectNames[i] == "ungrouped" { + shouldSwap = true + } else if projectNames[j] == "ungrouped" { + shouldSwap = false + } else if projectNames[i] > projectNames[j] { + shouldSwap = true + } + if shouldSwap { + projectNames[i], projectNames[j] = projectNames[j], projectNames[i] + } + } + } + + expected := []string{"alpha", "beta", "ungrouped"} + for i, name := range expected { + if projectNames[i] != name { + t.Errorf("expected projectNames[%d] = %q, got %q", i, name, projectNames[i]) + } + } + }) +} + +func TestProjectRoutesStruct(t *testing.T) { + t.Run("creates project routes correctly", func(t *testing.T) { + project := &ProjectRoutes{ + ProjectDir: "~/projects/myapp", + Routes: []RouteStatus{ + { + Host: "api.localhost", + Backend: "172.18.0.3:8080", + ContainerName: "myapp-api-1", + Protocol: "http", + }, + { + Host: "web.localhost", + Backend: "172.18.0.4:3000", + ContainerName: "myapp-web-1", + Protocol: "http", + }, + }, + } + + if project.ProjectDir != "~/projects/myapp" { + t.Errorf("expected ProjectDir '~/projects/myapp', got %q", project.ProjectDir) + } + + if len(project.Routes) != 2 { + t.Errorf("expected 2 routes, got %d", len(project.Routes)) + } + + if project.Routes[0].Host != "api.localhost" { + t.Errorf("expected first route host 'api.localhost', got %q", project.Routes[0].Host) + } + }) +} + +func TestStatusStruct(t *testing.T) { + t.Run("initializes projects map correctly", func(t *testing.T) { + status := Status{ + Running: true, + PID: 12345, + Entrypoints: []Entrypoint{}, + Projects: make(map[string]*ProjectRoutes), + } + + if status.Projects == nil { + t.Error("expected Projects map to be initialized") + } + + // Add a project + status.Projects["myapp"] = &ProjectRoutes{ + ProjectDir: "~/projects/myapp", + Routes: []RouteStatus{}, + } + + if len(status.Projects) != 1 { + t.Errorf("expected 1 project, got %d", len(status.Projects)) + } + }) + + t.Run("groups routes by project", func(t *testing.T) { + status := Status{ + Running: true, + Projects: make(map[string]*ProjectRoutes), + } + + // Simulate adding routes from different projects + routes := []struct { + host string + projectName string + projectDir string + }{ + {"api.localhost", "myapp", "/home/user/myapp"}, + {"web.localhost", "myapp", "/home/user/myapp"}, + {"db.localhost", "other", "/home/user/other"}, + {"static.localhost", "", ""}, // ungrouped + } + + for _, r := range routes { + projectName := r.projectName + if projectName == "" { + projectName = "ungrouped" + } + + project, exists := status.Projects[projectName] + if !exists { + project = &ProjectRoutes{ + ProjectDir: r.projectDir, + Routes: []RouteStatus{}, + } + status.Projects[projectName] = project + } + + project.Routes = append(project.Routes, RouteStatus{ + Host: r.host, + }) + } + + // Verify grouping + if len(status.Projects) != 3 { + t.Errorf("expected 3 projects, got %d", len(status.Projects)) + } + + if len(status.Projects["myapp"].Routes) != 2 { + t.Errorf("expected 2 routes in myapp, got %d", len(status.Projects["myapp"].Routes)) + } + + if len(status.Projects["other"].Routes) != 1 { + t.Errorf("expected 1 route in other, got %d", len(status.Projects["other"].Routes)) + } + + if len(status.Projects["ungrouped"].Routes) != 1 { + t.Errorf("expected 1 route in ungrouped, got %d", len(status.Projects["ungrouped"].Routes)) + } + }) +} diff --git a/internal/docker/sync.go b/internal/docker/sync.go index b2cbbe9..c55f8c6 100644 --- a/internal/docker/sync.go +++ b/internal/docker/sync.go @@ -129,6 +129,10 @@ func (s *RouteSync) handleStart(event ContainerEvent) { containerName = resolvedName } + // Extract Docker Compose project info from labels + projectName := event.Labels["com.docker.compose.project"] + projectDir := event.Labels["com.docker.compose.project.working_dir"] + // Register routes for each service var hosts []string s.logger.Debug("registering routes", "container", event.ContainerName, "count", len(configs)) @@ -151,6 +155,8 @@ func (s *RouteSync) handleStart(event ContainerEvent) { Entrypoint: config.Entrypoint, ContainerID: event.ContainerID, ContainerName: containerName, + ProjectName: projectName, + ProjectDir: projectDir, } if err := s.registry.Add(route); err != nil { diff --git a/internal/docker/sync_test.go b/internal/docker/sync_test.go index 2061d1d..263f5e7 100644 --- a/internal/docker/sync_test.go +++ b/internal/docker/sync_test.go @@ -424,6 +424,147 @@ func TestRouteSync_handleStart_FullFlow(t *testing.T) { }) } +func TestRouteSync_handleStart_ProjectInfo(t *testing.T) { + t.Run("extracts Docker Compose project info from labels", func(t *testing.T) { + registry := proxy.NewRegistry() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + mockAPI := newMockBuilder(). + withContainerInspect(func(ctx context.Context, containerID string) (container.InspectResponse, error) { + return makeContainerInspectResponse(containerID, "myapp-web-1", "172.17.0.5", "bridge"), nil + }). + build() + + client := NewClientWithAPI(mockAPI, logger) + sync := NewRouteSync(registry, client, "bridge", logger) + + event := ContainerEvent{ + ContainerID: "container123abc", + ContainerName: "myapp-web-1", + Labels: map[string]string{ + "devproxy.enable": "true", + "devproxy.host": "app.localhost", + "devproxy.port": "8080", + "com.docker.compose.project": "myapp", + "com.docker.compose.project.working_dir": "/home/user/projects/myapp", + "com.docker.compose.service": "web", + }, + Type: "start", + } + + sync.HandleEvent(event) + + // Verify route was added with project info + route := registry.Lookup("app.localhost") + if route == nil { + t.Fatal("expected route to be added") + } + + if route.ProjectName != "myapp" { + t.Errorf("expected ProjectName 'myapp', got '%s'", route.ProjectName) + } + + if route.ProjectDir != "/home/user/projects/myapp" { + t.Errorf("expected ProjectDir '/home/user/projects/myapp', got '%s'", route.ProjectDir) + } + }) + + t.Run("handles containers without Docker Compose labels", func(t *testing.T) { + registry := proxy.NewRegistry() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + mockAPI := newMockBuilder(). + withContainerInspect(func(ctx context.Context, containerID string) (container.InspectResponse, error) { + return makeContainerInspectResponse(containerID, "standalone-app", "172.17.0.6", "bridge"), nil + }). + build() + + client := NewClientWithAPI(mockAPI, logger) + sync := NewRouteSync(registry, client, "bridge", logger) + + event := ContainerEvent{ + ContainerID: "standalone456", + ContainerName: "standalone-app", + Labels: map[string]string{ + "devproxy.enable": "true", + "devproxy.host": "standalone.localhost", + "devproxy.port": "3000", + }, + Type: "start", + } + + sync.HandleEvent(event) + + // Verify route was added without project info + route := registry.Lookup("standalone.localhost") + if route == nil { + t.Fatal("expected route to be added") + } + + if route.ProjectName != "" { + t.Errorf("expected empty ProjectName, got '%s'", route.ProjectName) + } + + if route.ProjectDir != "" { + t.Errorf("expected empty ProjectDir, got '%s'", route.ProjectDir) + } + }) + + t.Run("applies same project info to multiple service configs", func(t *testing.T) { + registry := proxy.NewRegistry() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + mockAPI := newMockBuilder(). + withContainerInspect(func(ctx context.Context, containerID string) (container.InspectResponse, error) { + return makeContainerInspectResponse(containerID, "multi-service", "172.17.0.10", "bridge"), nil + }). + build() + + client := NewClientWithAPI(mockAPI, logger) + sync := NewRouteSync(registry, client, "bridge", logger) + + event := ContainerEvent{ + ContainerID: "multicontainer123", + ContainerName: "multi-service", + Labels: map[string]string{ + "devproxy.enable": "true", + "devproxy.services.web.host": "web.localhost", + "devproxy.services.web.port": "8080", + "devproxy.services.api.host": "api.localhost", + "devproxy.services.api.port": "3000", + "com.docker.compose.project": "multiproject", + "com.docker.compose.project.working_dir": "/projects/multi", + }, + Type: "start", + } + + sync.HandleEvent(event) + + // Verify both routes have the same project info + webRoute := registry.Lookup("web.localhost") + if webRoute == nil { + t.Fatal("expected web route to be added") + } + if webRoute.ProjectName != "multiproject" { + t.Errorf("expected web route ProjectName 'multiproject', got '%s'", webRoute.ProjectName) + } + if webRoute.ProjectDir != "/projects/multi" { + t.Errorf("expected web route ProjectDir '/projects/multi', got '%s'", webRoute.ProjectDir) + } + + apiRoute := registry.Lookup("api.localhost") + if apiRoute == nil { + t.Fatal("expected api route to be added") + } + if apiRoute.ProjectName != "multiproject" { + t.Errorf("expected api route ProjectName 'multiproject', got '%s'", apiRoute.ProjectName) + } + if apiRoute.ProjectDir != "/projects/multi" { + t.Errorf("expected api route ProjectDir '/projects/multi', got '%s'", apiRoute.ProjectDir) + } + }) +} + func TestRouteSync_handleStart_CertGeneration(t *testing.T) { t.Run("pre-generates certificate when cert manager is set", func(t *testing.T) { registry := proxy.NewRegistry() diff --git a/internal/proxy/routes.go b/internal/proxy/routes.go index 844a893..b1e7aff 100644 --- a/internal/proxy/routes.go +++ b/internal/proxy/routes.go @@ -52,6 +52,13 @@ type Route struct { // ContainerName is the Docker container name for display purposes. ContainerName string + // ProjectName is the Docker Compose project name (from com.docker.compose.project label). + ProjectName string + + // ProjectDir is the host directory containing docker-compose.yml + // (from com.docker.compose.project.working_dir label). + ProjectDir string + // CreatedAt is when the route was added. CreatedAt time.Time }