diff --git a/internal/api/server.go b/internal/api/server.go index 78d945a..c46c221 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -692,6 +692,7 @@ func (s *Server) createDeployment(c *gin.Context) { if req.TemplateID != "" { s.processTemplateFiles(req.Name, req.TemplateID, allEnvVars) + s.applyTemplateMountOwnership(req.Name, req.TemplateID) } if req.Metadata != nil { @@ -1795,12 +1796,14 @@ type TemplateMetadata struct { } type TemplateMount struct { - ID string `json:"id" yaml:"id"` - Name string `json:"name" yaml:"name"` - ContainerPath string `json:"container_path" yaml:"container_path"` - Description string `json:"description" yaml:"description"` - Type string `json:"type" yaml:"type"` - Required bool `json:"required" yaml:"required"` + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + ContainerPath string `json:"container_path" yaml:"container_path"` + Description string `json:"description" yaml:"description"` + Type string `json:"type" yaml:"type"` + Required bool `json:"required" yaml:"required"` + User string `json:"user,omitempty" yaml:"user,omitempty"` + Subdirectories []string `json:"subdirectories,omitempty" yaml:"subdirectories,omitempty"` } type Template struct { @@ -2679,6 +2682,44 @@ func (s *Server) processTemplateFiles(deploymentName, templateID string, envVars } } +func (s *Server) applyTemplateMountOwnership(deploymentName, templateID string) { + templatesDir := filepath.Join(s.config.DeploymentsPath, ".flatrun", "templates") + metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml") + + metadataContent, err := os.ReadFile(metadataPath) + if err != nil { + return + } + + var metadata TemplateMetadata + if err := yaml.Unmarshal(metadataContent, &metadata); err != nil { + return + } + + if len(metadata.Mounts) == 0 { + return + } + + var mounts []docker.MountOwnership + for _, m := range metadata.Mounts { + if m.Type != "file" { + continue + } + hostPath := "./" + m.ID + mounts = append(mounts, docker.MountOwnership{ + HostPath: hostPath, + User: m.User, + Subdirectories: m.Subdirectories, + }) + } + + if len(mounts) > 0 { + if err := s.manager.ApplyMountOwnership(deploymentName, mounts); err != nil { + log.Printf("Warning: failed to apply mount ownership for %s: %v", deploymentName, err) + } + } +} + func (s *Server) listCertificates(c *gin.Context) { certificates, err := s.proxyOrchestrator.ListCertificates() if err != nil { diff --git a/internal/docker/discovery.go b/internal/docker/discovery.go index 76dc749..b86df12 100644 --- a/internal/docker/discovery.go +++ b/internal/docker/discovery.go @@ -3,6 +3,7 @@ package docker import ( "fmt" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -28,6 +29,7 @@ type composeService struct { Image string `yaml:"image"` Ports []interface{} `yaml:"ports"` Networks []string `yaml:"networks"` + Volumes []string `yaml:"volumes"` } func (d *Discovery) FindDeployments() ([]models.Deployment, error) { @@ -281,7 +283,6 @@ func (d *Discovery) createBindMountDirs(deploymentPath, composeContent string) e if err := os.MkdirAll(fullPath, 0777); err != nil { return err } - // Ensure directory is writable by any user (for non-root containers) if err := os.Chmod(fullPath, 0777); err != nil { return err } @@ -314,6 +315,122 @@ func extractBindMountPath(volume string) string { return hostPath } +// MountOwnership describes ownership and subdirectory requirements for a bind mount. +type MountOwnership struct { + HostPath string + User string // "UID:GID" or empty + Subdirectories []string +} + +// ApplyMountOwnership sets ownership and creates subdirectories for bind mounts. +// When User is specified (UID:GID format), directories are chowned to that user. +// When User is empty, directories are chmod'd to 0777 as a fallback for non-template deploys. +func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwnership) error { + for _, m := range mounts { + base := m.HostPath + if !filepath.IsAbs(base) { + base = filepath.Join(deploymentPath, base) + } + + if err := os.MkdirAll(base, 0755); err != nil { + return fmt.Errorf("create mount dir %s: %w", base, err) + } + + dirs := []string{base} + for _, sub := range m.Subdirectories { + subPath := filepath.Join(base, sub) + if err := os.MkdirAll(subPath, 0755); err != nil { + return fmt.Errorf("create subdirectory %s: %w", subPath, err) + } + dirs = append(dirs, subPath) + } + + if m.User != "" { + uid, gid, err := parseUIDGID(m.User) + if err != nil { + return fmt.Errorf("parse user %q: %w", m.User, err) + } + for _, dir := range dirs { + if err := os.Chown(dir, uid, gid); err != nil { + return fmt.Errorf("chown %s: %w", dir, err) + } + } + } else { + for _, dir := range dirs { + if err := os.Chmod(dir, 0777); err != nil { + return fmt.Errorf("chmod %s: %w", dir, err) + } + } + } + } + return nil +} + +func parseUIDGID(user string) (int, int, error) { + parts := strings.SplitN(user, ":", 2) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("expected UID:GID format, got %q", user) + } + uid, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("invalid UID %q: %w", parts[0], err) + } + gid, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("invalid GID %q: %w", parts[1], err) + } + return uid, gid, nil +} + +// InspectContainerUser gets the UID:GID of the running process inside a container. +func InspectContainerUser(containerName string) (string, error) { + uidCmd := exec.Command("docker", "exec", containerName, "id", "-u") + uidOut, err := uidCmd.Output() + if err != nil { + return "", fmt.Errorf("get container uid: %w", err) + } + + gidCmd := exec.Command("docker", "exec", containerName, "id", "-g") + gidOut, err := gidCmd.Output() + if err != nil { + return "", fmt.Errorf("get container gid: %w", err) + } + + uid := strings.TrimSpace(string(uidOut)) + gid := strings.TrimSpace(string(gidOut)) + + return uid + ":" + gid, nil +} + +// ExtractBindMounts parses compose content and returns bind mount host paths. +func ExtractBindMounts(composeContent string) []string { + var compose composeFile + if err := yaml.Unmarshal([]byte(composeContent), &compose); err != nil { + return nil + } + + var paths []string + seen := make(map[string]bool) + + for _, service := range compose.Services { + for _, volume := range service.Volumes { + hostPath := extractBindMountPath(volume) + if hostPath == "" { + continue + } + if !strings.HasPrefix(hostPath, "./") && !strings.HasPrefix(hostPath, "../") { + continue + } + if !seen[hostPath] { + seen[hostPath] = true + paths = append(paths, hostPath) + } + } + } + + return paths +} + // ensureComposeName adds or updates the name attribute in a compose file func (d *Discovery) ensureComposeName(name string, content string) string { var compose map[string]interface{} diff --git a/internal/docker/discovery_test.go b/internal/docker/discovery_test.go index 172926e..f15756d 100644 --- a/internal/docker/discovery_test.go +++ b/internal/docker/discovery_test.go @@ -1,6 +1,7 @@ package docker import ( + "fmt" "os" "path/filepath" "testing" @@ -222,3 +223,209 @@ services: } } } + +func TestApplyMountOwnership(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "test-ownership-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + d := NewDiscovery(tmpDir) + deploymentPath := filepath.Join(tmpDir, "test-deploy") + if err := os.MkdirAll(deploymentPath, 0755); err != nil { + t.Fatalf("Failed to create deployment dir: %v", err) + } + + t.Run("creates subdirectories with user ownership", func(t *testing.T) { + currentUID := os.Getuid() + currentGID := os.Getgid() + user := fmt.Sprintf("%d:%d", currentUID, currentGID) + + mounts := []MountOwnership{ + { + HostPath: "./storage", + User: user, + Subdirectories: []string{"framework/cache", "framework/sessions", "logs"}, + }, + } + + err := d.ApplyMountOwnership(deploymentPath, mounts) + if err != nil { + t.Fatalf("ApplyMountOwnership failed: %v", err) + } + + basePath := filepath.Join(deploymentPath, "storage") + if _, err := os.Stat(basePath); err != nil { + t.Errorf("Expected storage dir to exist: %v", err) + } + + for _, sub := range []string{"framework/cache", "framework/sessions", "logs"} { + subPath := filepath.Join(basePath, sub) + if _, err := os.Stat(subPath); err != nil { + t.Errorf("Expected subdirectory %q to exist: %v", sub, err) + } + } + }) + + t.Run("falls back to 0777 when no user specified", func(t *testing.T) { + mounts := []MountOwnership{ + { + HostPath: "./nouser", + }, + } + + err := d.ApplyMountOwnership(deploymentPath, mounts) + if err != nil { + t.Fatalf("ApplyMountOwnership failed: %v", err) + } + + info, err := os.Stat(filepath.Join(deploymentPath, "nouser")) + if err != nil { + t.Fatalf("Expected nouser dir to exist: %v", err) + } + if info.Mode().Perm() != 0777 { + t.Errorf("Expected 0777 permissions, got %o", info.Mode().Perm()) + } + }) + + t.Run("rejects invalid user format", func(t *testing.T) { + mounts := []MountOwnership{ + { + HostPath: "./baduser", + User: "notvalid", + }, + } + + err := d.ApplyMountOwnership(deploymentPath, mounts) + if err == nil { + t.Fatal("Expected error for invalid user format") + } + }) +} + +func TestParseUIDGID(t *testing.T) { + tests := []struct { + name string + input string + wantUID int + wantGID int + wantError bool + }{ + {name: "valid pair", input: "33:33", wantUID: 33, wantGID: 33}, + {name: "different values", input: "1000:1001", wantUID: 1000, wantGID: 1001}, + {name: "root", input: "0:0", wantUID: 0, wantGID: 0}, + {name: "missing colon", input: "1000", wantError: true}, + {name: "non-numeric uid", input: "abc:100", wantError: true}, + {name: "non-numeric gid", input: "100:abc", wantError: true}, + {name: "empty string", input: "", wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + uid, gid, err := parseUIDGID(tt.input) + if tt.wantError { + if err == nil { + t.Errorf("parseUIDGID(%q) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Fatalf("parseUIDGID(%q) unexpected error: %v", tt.input, err) + } + if uid != tt.wantUID { + t.Errorf("parseUIDGID(%q) uid = %d, want %d", tt.input, uid, tt.wantUID) + } + if gid != tt.wantGID { + t.Errorf("parseUIDGID(%q) gid = %d, want %d", tt.input, gid, tt.wantGID) + } + }) + } +} + +func TestExtractBindMounts(t *testing.T) { + tests := []struct { + name string + compose string + expected []string + }{ + { + name: "single bind mount", + compose: `services: + app: + image: nginx + volumes: + - ./data:/var/data +`, + expected: []string{"./data"}, + }, + { + name: "multiple bind mounts", + compose: `services: + app: + image: nginx + volumes: + - ./app:/app + - ./config:/etc/config +`, + expected: []string{"./app", "./config"}, + }, + { + name: "skips named volumes", + compose: `services: + app: + image: nginx + volumes: + - ./data:/data + - myvolume:/var/lib +`, + expected: []string{"./data"}, + }, + { + name: "multiple services", + compose: `services: + web: + volumes: + - ./web:/app + worker: + volumes: + - ./worker:/app +`, + expected: []string{"./web", "./worker"}, + }, + { + name: "deduplicates shared mounts", + compose: `services: + web: + volumes: + - ./shared:/data + worker: + volumes: + - ./shared:/data +`, + expected: []string{"./shared"}, + }, + { + name: "no volumes", + compose: `services: + app: + image: nginx +`, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractBindMounts(tt.compose) + if len(result) != len(tt.expected) { + t.Fatalf("ExtractBindMounts returned %d paths, want %d: got %v", len(result), len(tt.expected), result) + } + for i, path := range tt.expected { + if result[i] != path { + t.Errorf("ExtractBindMounts[%d] = %q, want %q", i, result[i], path) + } + } + }) + } +} diff --git a/internal/docker/manager.go b/internal/docker/manager.go index beaf8f0..9c61053 100644 --- a/internal/docker/manager.go +++ b/internal/docker/manager.go @@ -3,6 +3,7 @@ package docker import ( "encoding/json" "fmt" + "path/filepath" "strings" "sync" "time" @@ -122,6 +123,11 @@ func (m *Manager) CreateDeployment(name string, composeContent string) error { return m.discovery.CreateDeployment(name, composeContent) } +func (m *Manager) ApplyMountOwnership(name string, mounts []MountOwnership) error { + deploymentPath := filepath.Join(m.basePath, name) + return m.discovery.ApplyMountOwnership(deploymentPath, mounts) +} + func (m *Manager) DeleteDeployment(name string) error { m.mu.Lock() defer m.mu.Unlock() @@ -145,7 +151,90 @@ func (m *Manager) StartDeployment(name string) (string, error) { return "", err } - return m.executor.Up(deployment.Path) + output, err := m.executor.Up(deployment.Path) + if err != nil { + return output, err + } + + go m.applyMountOwnershipFromContainer(name, deployment.Path) + + return output, nil +} + +func (m *Manager) applyMountOwnershipFromContainer(name, deploymentPath string) { + composeContent, _, err := m.discovery.GetComposeFile(name) + if err != nil { + return + } + + bindMounts := ExtractBindMounts(composeContent) + if len(bindMounts) == 0 { + return + } + + containerName := m.getMainContainerName(deploymentPath) + if containerName == "" { + containerName = name + } + + user, err := InspectContainerUser(containerName) + if err != nil { + return + } + + if user == "0:0" { + return + } + + var mounts []MountOwnership + for _, path := range bindMounts { + mounts = append(mounts, MountOwnership{ + HostPath: path, + User: user, + }) + } + + _ = m.discovery.ApplyMountOwnership(deploymentPath, mounts) +} + +func (m *Manager) getMainContainerName(deploymentPath string) string { + output, err := m.executor.PS(deploymentPath) + if err != nil { + return "" + } + + var containers []composeContainer + trimmed := strings.TrimSpace(output) + + if strings.HasPrefix(trimmed, "[") { + _ = json.Unmarshal([]byte(trimmed), &containers) + } + + if len(containers) == 0 { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || line == "[]" { + continue + } + var container composeContainer + if err := json.Unmarshal([]byte(line), &container); err != nil { + continue + } + containers = append(containers, container) + } + } + + for _, c := range containers { + if c.Service == "app" || c.Service == "web" { + return c.Name + } + } + + if len(containers) > 0 { + return containers[0].Name + } + + return "" } func (m *Manager) StopDeployment(name string) (string, error) { @@ -181,7 +270,14 @@ func (m *Manager) RebuildDeployment(name string) (string, error) { return "", err } - return m.executor.Rebuild(deployment.Path) + output, err := m.executor.Rebuild(deployment.Path) + if err != nil { + return output, err + } + + go m.applyMountOwnershipFromContainer(name, deployment.Path) + + return output, nil } func (m *Manager) PullDeployment(name string, onlyLatest bool) (string, error) { diff --git a/templates/ghost/docker-compose.yml b/templates/ghost/docker-compose.yml index 6ac9dce..3cefe23 100644 --- a/templates/ghost/docker-compose.yml +++ b/templates/ghost/docker-compose.yml @@ -11,16 +11,13 @@ services: database__connection__password: ${DB_PASSWORD:-changeme} database__connection__database: ${DB_DATABASE:-ghost} volumes: - - ghost_content:/var/lib/ghost/content + - ./data:/var/lib/ghost/content expose: - "2368" networks: - proxy restart: unless-stopped -volumes: - ghost_content: - networks: proxy: external: true diff --git a/templates/ghost/metadata.yml b/templates/ghost/metadata.yml index fbebab3..298993f 100644 --- a/templates/ghost/metadata.yml +++ b/templates/ghost/metadata.yml @@ -10,5 +10,6 @@ mounts: name: Content container_path: /var/lib/ghost/content description: Ghost content, themes, and images - type: volume + type: file required: true + user: "1000:1000" diff --git a/templates/laravel/metadata.yml b/templates/laravel/metadata.yml index e159b5e..1091336 100644 --- a/templates/laravel/metadata.yml +++ b/templates/laravel/metadata.yml @@ -33,18 +33,26 @@ mounts: description: Laravel application source code type: file required: true + user: "1000:1000" - id: env name: Environment Config container_path: /app/.env description: Laravel environment configuration file type: file required: true + user: "1000:1000" - id: storage name: Storage container_path: /app/storage description: Logs, cache, and file uploads - type: volume + type: file required: false + user: "1000:1000" + subdirectories: + - framework/cache + - framework/sessions + - framework/views + - logs files: - path: .env content: | diff --git a/templates/nextcloud/docker-compose.yml b/templates/nextcloud/docker-compose.yml index ab1e3e8..4674925 100644 --- a/templates/nextcloud/docker-compose.yml +++ b/templates/nextcloud/docker-compose.yml @@ -5,9 +5,9 @@ services: ports: - "${PORT:-8080}:80" volumes: - - nextcloud_data:/var/www/html/data - - nextcloud_config:/var/www/html/config - - nextcloud_apps:/var/www/html/custom_apps + - ./data:/var/www/html/data + - ./config:/var/www/html/config + - ./apps:/var/www/html/custom_apps environment: - MYSQL_HOST=${DB_HOST:-db} - MYSQL_DATABASE=${DB_DATABASE:-nextcloud} @@ -20,11 +20,6 @@ services: - default - database -volumes: - nextcloud_data: - nextcloud_config: - nextcloud_apps: - networks: database: external: true diff --git a/templates/nextcloud/metadata.yml b/templates/nextcloud/metadata.yml index 6a53a86..b1cb814 100644 --- a/templates/nextcloud/metadata.yml +++ b/templates/nextcloud/metadata.yml @@ -10,17 +10,20 @@ mounts: name: User Data container_path: /var/www/html/data description: User files and data storage - type: volume + type: file required: true + user: "33:33" - id: config name: Configuration container_path: /var/www/html/config description: Nextcloud configuration files - type: volume + type: file required: true + user: "33:33" - id: apps name: Custom Apps container_path: /var/www/html/custom_apps description: Custom Nextcloud applications - type: volume + type: file required: false + user: "33:33" diff --git a/templates/wordpress/docker-compose.yml b/templates/wordpress/docker-compose.yml index 3fb058a..df9a18c 100644 --- a/templates/wordpress/docker-compose.yml +++ b/templates/wordpress/docker-compose.yml @@ -14,16 +14,13 @@ services: $$_SERVER['HTTPS'] = 'on'; } volumes: - - ${NAME}_data:/var/www/html + - ./data:/var/www/html expose: - "80" networks: - proxy restart: unless-stopped -volumes: - ${NAME}_data: - networks: proxy: external: true diff --git a/templates/wordpress/metadata.yml b/templates/wordpress/metadata.yml index 3fa9489..d46b590 100644 --- a/templates/wordpress/metadata.yml +++ b/templates/wordpress/metadata.yml @@ -12,18 +12,21 @@ mounts: description: Bind mount for entire WordPress installation (advanced) type: file required: false + user: "33:33" - id: uploads name: Media Uploads container_path: /var/www/html/wp-content/uploads description: Bind mount for media uploads only type: file required: false + user: "33:33" - id: plugins name: Plugins container_path: /var/www/html/wp-content/plugins description: Bind mount for WordPress plugins type: file required: false + user: "33:33" backup: container_paths: - service: wordpress