Skip to content

Commit 4cff2cb

Browse files
committed
fix(docker): Resolve bind mount permission issues for containers
- Add User and Subdirectories fields to TemplateMount for declaring container ownership requirements - Add ApplyMountOwnership to set correct UID:GID on bind mount dirs - Inspect container user after start and apply ownership automatically - Convert template volumes from named to bind mounts (wordpress, ghost, nextcloud) - Add user field to template metadata (www-data 33:33, bitnami 1000:1000) - Add Laravel storage subdirectories (framework/cache, sessions, views) Closes #50 Signed-off-by: nfebe <fenn25.fn@gmail.com>
1 parent 3efe0ed commit 4cff2cb

11 files changed

Lines changed: 446 additions & 30 deletions

File tree

internal/api/server.go

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,7 @@ func (s *Server) createDeployment(c *gin.Context) {
692692

693693
if req.TemplateID != "" {
694694
s.processTemplateFiles(req.Name, req.TemplateID, allEnvVars)
695+
s.applyTemplateMountOwnership(req.Name, req.TemplateID)
695696
}
696697

697698
if req.Metadata != nil {
@@ -1795,12 +1796,14 @@ type TemplateMetadata struct {
17951796
}
17961797

17971798
type TemplateMount struct {
1798-
ID string `json:"id" yaml:"id"`
1799-
Name string `json:"name" yaml:"name"`
1800-
ContainerPath string `json:"container_path" yaml:"container_path"`
1801-
Description string `json:"description" yaml:"description"`
1802-
Type string `json:"type" yaml:"type"`
1803-
Required bool `json:"required" yaml:"required"`
1799+
ID string `json:"id" yaml:"id"`
1800+
Name string `json:"name" yaml:"name"`
1801+
ContainerPath string `json:"container_path" yaml:"container_path"`
1802+
Description string `json:"description" yaml:"description"`
1803+
Type string `json:"type" yaml:"type"`
1804+
Required bool `json:"required" yaml:"required"`
1805+
User string `json:"user,omitempty" yaml:"user,omitempty"`
1806+
Subdirectories []string `json:"subdirectories,omitempty" yaml:"subdirectories,omitempty"`
18041807
}
18051808

18061809
type Template struct {
@@ -2679,6 +2682,44 @@ func (s *Server) processTemplateFiles(deploymentName, templateID string, envVars
26792682
}
26802683
}
26812684

2685+
func (s *Server) applyTemplateMountOwnership(deploymentName, templateID string) {
2686+
templatesDir := filepath.Join(s.config.DeploymentsPath, ".flatrun", "templates")
2687+
metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml")
2688+
2689+
metadataContent, err := os.ReadFile(metadataPath)
2690+
if err != nil {
2691+
return
2692+
}
2693+
2694+
var metadata TemplateMetadata
2695+
if err := yaml.Unmarshal(metadataContent, &metadata); err != nil {
2696+
return
2697+
}
2698+
2699+
if len(metadata.Mounts) == 0 {
2700+
return
2701+
}
2702+
2703+
var mounts []docker.MountOwnership
2704+
for _, m := range metadata.Mounts {
2705+
if m.Type != "file" {
2706+
continue
2707+
}
2708+
hostPath := "./" + m.ID
2709+
mounts = append(mounts, docker.MountOwnership{
2710+
HostPath: hostPath,
2711+
User: m.User,
2712+
Subdirectories: m.Subdirectories,
2713+
})
2714+
}
2715+
2716+
if len(mounts) > 0 {
2717+
if err := s.manager.ApplyMountOwnership(deploymentName, mounts); err != nil {
2718+
log.Printf("Warning: failed to apply mount ownership for %s: %v", deploymentName, err)
2719+
}
2720+
}
2721+
}
2722+
26822723
func (s *Server) listCertificates(c *gin.Context) {
26832724
certificates, err := s.proxyOrchestrator.ListCertificates()
26842725
if err != nil {

internal/docker/discovery.go

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package docker
33
import (
44
"fmt"
55
"os"
6+
"os/exec"
67
"path/filepath"
78
"strconv"
89
"strings"
@@ -28,6 +29,7 @@ type composeService struct {
2829
Image string `yaml:"image"`
2930
Ports []interface{} `yaml:"ports"`
3031
Networks []string `yaml:"networks"`
32+
Volumes []string `yaml:"volumes"`
3133
}
3234

3335
func (d *Discovery) FindDeployments() ([]models.Deployment, error) {
@@ -281,7 +283,6 @@ func (d *Discovery) createBindMountDirs(deploymentPath, composeContent string) e
281283
if err := os.MkdirAll(fullPath, 0777); err != nil {
282284
return err
283285
}
284-
// Ensure directory is writable by any user (for non-root containers)
285286
if err := os.Chmod(fullPath, 0777); err != nil {
286287
return err
287288
}
@@ -314,6 +315,122 @@ func extractBindMountPath(volume string) string {
314315
return hostPath
315316
}
316317

318+
// MountOwnership describes ownership and subdirectory requirements for a bind mount.
319+
type MountOwnership struct {
320+
HostPath string
321+
User string // "UID:GID" or empty
322+
Subdirectories []string
323+
}
324+
325+
// ApplyMountOwnership sets ownership and creates subdirectories for bind mounts.
326+
// When User is specified (UID:GID format), directories are chowned to that user.
327+
// When User is empty, directories are chmod'd to 0777 as a fallback for non-template deploys.
328+
func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwnership) error {
329+
for _, m := range mounts {
330+
base := m.HostPath
331+
if !filepath.IsAbs(base) {
332+
base = filepath.Join(deploymentPath, base)
333+
}
334+
335+
if err := os.MkdirAll(base, 0755); err != nil {
336+
return fmt.Errorf("create mount dir %s: %w", base, err)
337+
}
338+
339+
dirs := []string{base}
340+
for _, sub := range m.Subdirectories {
341+
subPath := filepath.Join(base, sub)
342+
if err := os.MkdirAll(subPath, 0755); err != nil {
343+
return fmt.Errorf("create subdirectory %s: %w", subPath, err)
344+
}
345+
dirs = append(dirs, subPath)
346+
}
347+
348+
if m.User != "" {
349+
uid, gid, err := parseUIDGID(m.User)
350+
if err != nil {
351+
return fmt.Errorf("parse user %q: %w", m.User, err)
352+
}
353+
for _, dir := range dirs {
354+
if err := os.Chown(dir, uid, gid); err != nil {
355+
return fmt.Errorf("chown %s: %w", dir, err)
356+
}
357+
}
358+
} else {
359+
for _, dir := range dirs {
360+
if err := os.Chmod(dir, 0777); err != nil {
361+
return fmt.Errorf("chmod %s: %w", dir, err)
362+
}
363+
}
364+
}
365+
}
366+
return nil
367+
}
368+
369+
func parseUIDGID(user string) (int, int, error) {
370+
parts := strings.SplitN(user, ":", 2)
371+
if len(parts) != 2 {
372+
return 0, 0, fmt.Errorf("expected UID:GID format, got %q", user)
373+
}
374+
uid, err := strconv.Atoi(parts[0])
375+
if err != nil {
376+
return 0, 0, fmt.Errorf("invalid UID %q: %w", parts[0], err)
377+
}
378+
gid, err := strconv.Atoi(parts[1])
379+
if err != nil {
380+
return 0, 0, fmt.Errorf("invalid GID %q: %w", parts[1], err)
381+
}
382+
return uid, gid, nil
383+
}
384+
385+
// InspectContainerUser gets the UID:GID of the running process inside a container.
386+
func InspectContainerUser(containerName string) (string, error) {
387+
uidCmd := exec.Command("docker", "exec", containerName, "id", "-u")
388+
uidOut, err := uidCmd.Output()
389+
if err != nil {
390+
return "", fmt.Errorf("get container uid: %w", err)
391+
}
392+
393+
gidCmd := exec.Command("docker", "exec", containerName, "id", "-g")
394+
gidOut, err := gidCmd.Output()
395+
if err != nil {
396+
return "", fmt.Errorf("get container gid: %w", err)
397+
}
398+
399+
uid := strings.TrimSpace(string(uidOut))
400+
gid := strings.TrimSpace(string(gidOut))
401+
402+
return uid + ":" + gid, nil
403+
}
404+
405+
// ExtractBindMounts parses compose content and returns bind mount host paths.
406+
func ExtractBindMounts(composeContent string) []string {
407+
var compose composeFile
408+
if err := yaml.Unmarshal([]byte(composeContent), &compose); err != nil {
409+
return nil
410+
}
411+
412+
var paths []string
413+
seen := make(map[string]bool)
414+
415+
for _, service := range compose.Services {
416+
for _, volume := range service.Volumes {
417+
hostPath := extractBindMountPath(volume)
418+
if hostPath == "" {
419+
continue
420+
}
421+
if !strings.HasPrefix(hostPath, "./") && !strings.HasPrefix(hostPath, "../") {
422+
continue
423+
}
424+
if !seen[hostPath] {
425+
seen[hostPath] = true
426+
paths = append(paths, hostPath)
427+
}
428+
}
429+
}
430+
431+
return paths
432+
}
433+
317434
// ensureComposeName adds or updates the name attribute in a compose file
318435
func (d *Discovery) ensureComposeName(name string, content string) string {
319436
var compose map[string]interface{}

0 commit comments

Comments
 (0)