Skip to content

Commit 069fe3f

Browse files
authored
feat: support for symlink (stow) project directories (getarcaneapp#2084)
1 parent abc4357 commit 069fe3f

File tree

16 files changed

+516
-40
lines changed

16 files changed

+516
-40
lines changed

backend/internal/models/settings.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func (s SettingVariable) AsDurationSeconds() time.Duration {
4545
type Settings struct {
4646
// General category
4747
ProjectsDirectory SettingVariable `key:"projectsDirectory,envOverride" meta:"label=Projects Directory;type=text;keywords=projects,directory,path,folder,location,storage,files,compose,docker-compose;category=internal;description=Configure where project files are stored"`
48+
FollowProjectSymlinks SettingVariable `key:"followProjectSymlinks,envOverride" meta:"label=Follow Project Symlinks;type=boolean;keywords=projects,symlink,symlinks,symbolic links,compose,directory,discovery;category=general;description=Treat symlinked child directories inside the projects directory as Docker Compose projects"`
4849
DiskUsagePath SettingVariable `key:"diskUsagePath" meta:"label=Disk Usage Path;type=text;keywords=disk,usage,path,storage,folder,files;category=general;description=Path used for disk usage calculations"`
4950
BaseServerURL SettingVariable `key:"baseServerUrl" meta:"label=Base Server URL;type=text;keywords=base,url,server,domain,host,endpoint,address,link;category=general;description=Set the base URL for the application"`
5051
EnableGravatar SettingVariable `key:"enableGravatar" meta:"label=Enable Gravatar;type=boolean;keywords=gravatar,avatar,profile,picture,image,user,photo;category=general;description=Enable Gravatar profile pictures for users"`

backend/internal/services/project_service.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ func (s *ProjectService) enrichWithComposeServiceConfigs(ctx context.Context, pr
506506

507507
func (s *ProjectService) SyncProjectsFromFileSystem(ctx context.Context) error {
508508
projectsDirSetting := s.settingsService.GetStringSetting(ctx, "projectsDirectory", "/app/data/projects")
509+
followProjectSymlinks := s.settingsService.GetBoolSetting(ctx, "followProjectSymlinks", false)
509510
projectsDir, err := projects.GetProjectsDirectory(ctx, strings.TrimSpace(projectsDirSetting))
510511
if err != nil {
511512
slog.WarnContext(ctx, "unable to prepare projects directory", "error", err)
@@ -521,11 +522,11 @@ func (s *ProjectService) SyncProjectsFromFileSystem(ctx context.Context) error {
521522

522523
seen := map[string]struct{}{}
523524
for _, e := range entries {
524-
if !e.IsDir() {
525+
dirPath := filepath.Join(projectsDir, e.Name())
526+
if !projects.IsProjectDirectoryEntry(e, dirPath, followProjectSymlinks) {
525527
continue
526528
}
527529
dirName := e.Name()
528-
dirPath := filepath.Join(projectsDir, dirName)
529530

530531
// Only consider folders that contain a compose file
531532
if _, derr := projects.DetectComposeFile(dirPath); derr != nil {
@@ -539,7 +540,7 @@ func (s *ProjectService) SyncProjectsFromFileSystem(ctx context.Context) error {
539540
seen[dirPath] = struct{}{}
540541
}
541542

542-
if cerr := s.cleanupDBProjects(ctx, seen); cerr != nil {
543+
if cerr := s.cleanupDBProjects(ctx, seen, followProjectSymlinks); cerr != nil {
543544
slog.WarnContext(ctx, "error during DB cleanup of projects", "error", cerr)
544545
}
545546

@@ -598,7 +599,7 @@ func (s *ProjectService) upsertProjectForDir(ctx context.Context, dirName, dirPa
598599
return nil
599600
}
600601

601-
func (s *ProjectService) cleanupDBProjects(ctx context.Context, seen map[string]struct{}) error {
602+
func (s *ProjectService) cleanupDBProjects(ctx context.Context, seen map[string]struct{}, followProjectSymlinks bool) error {
602603
var all []models.Project
603604
if err := s.db.WithContext(ctx).Find(&all).Error; err != nil {
604605
return fmt.Errorf("list projects for cleanup failed: %w", err)
@@ -610,18 +611,23 @@ func (s *ProjectService) cleanupDBProjects(ctx context.Context, seen map[string]
610611
continue
611612
}
612613

613-
// Remove if path missing or compose file missing
614-
if _, err := os.Stat(p.Path); err != nil {
614+
validDir, err := projects.IsProjectDirectoryPath(p.Path, followProjectSymlinks)
615+
if err != nil {
615616
if os.IsNotExist(err) {
616617
if derr := s.db.WithContext(ctx).Delete(&models.Project{}, "id = ?", p.ID).Error; derr != nil {
617618
slog.WarnContext(ctx, "failed to delete missing-path project", "projectID", p.ID, "error", derr)
618619
}
619620
continue
620621
}
621-
// On unexpected stat error, skip deletion but warn
622622
slog.WarnContext(ctx, "stat error during cleanup", "path", p.Path, "error", err)
623623
continue
624624
}
625+
if !validDir {
626+
if derr := s.db.WithContext(ctx).Delete(&models.Project{}, "id = ?", p.ID).Error; derr != nil {
627+
slog.WarnContext(ctx, "failed to delete non-project path", "projectID", p.ID, "path", p.Path, "error", derr)
628+
}
629+
continue
630+
}
625631

626632
if _, err := projects.DetectComposeFile(p.Path); err != nil {
627633
if derr := s.db.WithContext(ctx).Delete(&models.Project{}, "id = ?", p.ID).Error; derr != nil {
@@ -666,6 +672,7 @@ func formatDockerPorts(ports []container.PortSummary) []string {
666672

667673
func (s *ProjectService) countProjectFolders(ctx context.Context) (int, error) {
668674
projectsDirSetting := s.settingsService.GetStringSetting(ctx, "projectsDirectory", "/app/data/projects")
675+
followProjectSymlinks := s.settingsService.GetBoolSetting(ctx, "followProjectSymlinks", false)
669676
projectsDir, err := projects.GetProjectsDirectory(ctx, strings.TrimSpace(projectsDirSetting))
670677
if err != nil {
671678
return 0, fmt.Errorf("could not determine projects directory: %w", err)
@@ -691,10 +698,10 @@ func (s *ProjectService) countProjectFolders(ctx context.Context) (int, error) {
691698

692699
count := 0
693700
for _, e := range entries {
694-
if !e.IsDir() {
701+
dirPath := filepath.Join(projectsDir, e.Name())
702+
if !projects.IsProjectDirectoryEntry(e, dirPath, followProjectSymlinks) {
695703
continue
696704
}
697-
dirPath := filepath.Join(projectsDir, e.Name())
698705
if _, err := projects.DetectComposeFile(dirPath); err == nil {
699706
count++
700707
}

backend/internal/services/project_service_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1421,6 +1421,178 @@ func TestResolveBuildContextInternal_RejectsUnsupportedRemoteContext(t *testing.
14211421
assert.Contains(t, err.Error(), "only git repository URLs are supported")
14221422
}
14231423

1424+
func TestProjectService_SyncProjectsFromFileSystem_IgnoresSymlinkedProjectDirsWhenDisabled(t *testing.T) {
1425+
db := setupProjectTestDB(t)
1426+
ctx := context.Background()
1427+
1428+
settingsService, err := NewSettingsService(ctx, db)
1429+
require.NoError(t, err)
1430+
1431+
projectsRoot := t.TempDir()
1432+
targetRoot := t.TempDir()
1433+
createComposeProjectDir(t, projectsRoot, "regular")
1434+
linkTarget := createComposeProjectDir(t, targetRoot, "linked-target")
1435+
require.NoError(t, os.Symlink(linkTarget, filepath.Join(projectsRoot, "linked")))
1436+
1437+
require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot))
1438+
require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "false"))
1439+
1440+
svc := NewProjectService(db, settingsService, nil, nil, nil, nil)
1441+
require.NoError(t, svc.SyncProjectsFromFileSystem(ctx))
1442+
1443+
items, err := svc.ListAllProjects(ctx)
1444+
require.NoError(t, err)
1445+
require.Len(t, items, 1)
1446+
assert.Equal(t, "regular", items[0].Name)
1447+
assert.Equal(t, filepath.Join(projectsRoot, "regular"), items[0].Path)
1448+
}
1449+
1450+
func TestProjectService_SyncProjectsFromFileSystem_DetectsSymlinkedProjectDirsWhenEnabled(t *testing.T) {
1451+
db := setupProjectTestDB(t)
1452+
ctx := context.Background()
1453+
1454+
settingsService, err := NewSettingsService(ctx, db)
1455+
require.NoError(t, err)
1456+
1457+
projectsRoot := t.TempDir()
1458+
targetRoot := t.TempDir()
1459+
linkTarget := createComposeProjectDir(t, targetRoot, "linked-target")
1460+
linkPath := filepath.Join(projectsRoot, "linked")
1461+
require.NoError(t, os.Symlink(linkTarget, linkPath))
1462+
1463+
require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot))
1464+
require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true"))
1465+
1466+
svc := NewProjectService(db, settingsService, nil, nil, nil, nil)
1467+
require.NoError(t, svc.SyncProjectsFromFileSystem(ctx))
1468+
1469+
items, err := svc.ListAllProjects(ctx)
1470+
require.NoError(t, err)
1471+
require.Len(t, items, 1)
1472+
assert.Equal(t, "linked", items[0].Name)
1473+
assert.Equal(t, linkPath, items[0].Path)
1474+
}
1475+
1476+
func TestProjectService_CountProjectFolders_RespectsFollowProjectSymlinks(t *testing.T) {
1477+
db := setupProjectTestDB(t)
1478+
ctx := context.Background()
1479+
1480+
settingsService, err := NewSettingsService(ctx, db)
1481+
require.NoError(t, err)
1482+
1483+
projectsRoot := t.TempDir()
1484+
targetRoot := t.TempDir()
1485+
createComposeProjectDir(t, projectsRoot, "regular")
1486+
linkTarget := createComposeProjectDir(t, targetRoot, "linked-target")
1487+
require.NoError(t, os.Symlink(linkTarget, filepath.Join(projectsRoot, "linked")))
1488+
1489+
require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot))
1490+
1491+
svc := NewProjectService(db, settingsService, nil, nil, nil, nil)
1492+
1493+
require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "false"))
1494+
count, err := svc.countProjectFolders(ctx)
1495+
require.NoError(t, err)
1496+
assert.Equal(t, 1, count)
1497+
1498+
require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true"))
1499+
count, err = svc.countProjectFolders(ctx)
1500+
require.NoError(t, err)
1501+
assert.Equal(t, 2, count)
1502+
}
1503+
1504+
func TestProjectService_SyncProjectsFromFileSystem_RemovesSymlinkedProjectsWhenDisabled(t *testing.T) {
1505+
db := setupProjectTestDB(t)
1506+
ctx := context.Background()
1507+
1508+
settingsService, err := NewSettingsService(ctx, db)
1509+
require.NoError(t, err)
1510+
1511+
projectsRoot := t.TempDir()
1512+
targetRoot := t.TempDir()
1513+
linkTarget := createComposeProjectDir(t, targetRoot, "linked-target")
1514+
linkPath := filepath.Join(projectsRoot, "linked")
1515+
require.NoError(t, os.Symlink(linkTarget, linkPath))
1516+
1517+
require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot))
1518+
require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true"))
1519+
1520+
svc := NewProjectService(db, settingsService, nil, nil, nil, nil)
1521+
require.NoError(t, svc.SyncProjectsFromFileSystem(ctx))
1522+
1523+
items, err := svc.ListAllProjects(ctx)
1524+
require.NoError(t, err)
1525+
require.Len(t, items, 1)
1526+
1527+
require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "false"))
1528+
require.NoError(t, svc.SyncProjectsFromFileSystem(ctx))
1529+
1530+
items, err = svc.ListAllProjects(ctx)
1531+
require.NoError(t, err)
1532+
assert.Empty(t, items)
1533+
1534+
_, statErr := os.Lstat(linkPath)
1535+
require.NoError(t, statErr)
1536+
}
1537+
1538+
func TestProjectService_UpdateProject_WritesThroughSymlinkedProjectPath(t *testing.T) {
1539+
db := setupProjectTestDB(t)
1540+
ctx := context.Background()
1541+
1542+
settingsService, err := NewSettingsService(ctx, db)
1543+
require.NoError(t, err)
1544+
1545+
projectsRoot := t.TempDir()
1546+
targetRoot := t.TempDir()
1547+
targetPath := createComposeProjectDir(t, targetRoot, "demo-target")
1548+
linkPath := filepath.Join(projectsRoot, "demo")
1549+
require.NoError(t, os.Symlink(targetPath, linkPath))
1550+
1551+
require.NoError(t, settingsService.SetStringSetting(ctx, "projectsDirectory", projectsRoot))
1552+
require.NoError(t, settingsService.SetStringSetting(ctx, "followProjectSymlinks", "true"))
1553+
1554+
eventService := NewEventService(db, nil, nil)
1555+
svc := NewProjectService(db, settingsService, eventService, nil, nil, nil)
1556+
1557+
project := &models.Project{
1558+
BaseModel: models.BaseModel{ID: "proj-symlink-update"},
1559+
Name: "demo",
1560+
DirName: ptr("demo"),
1561+
Path: linkPath,
1562+
Status: models.ProjectStatusStopped,
1563+
}
1564+
require.NoError(t, db.Create(project).Error)
1565+
1566+
updatedCompose := "services:\n app:\n image: nginx:1.27-alpine\n"
1567+
updatedEnv := "FOO=updated\n"
1568+
1569+
updated, err := svc.UpdateProject(ctx, project.ID, nil, ptr(updatedCompose), ptr(updatedEnv), models.User{
1570+
BaseModel: models.BaseModel{ID: "u1"},
1571+
Username: "tester",
1572+
})
1573+
require.NoError(t, err)
1574+
require.NotNil(t, updated)
1575+
assert.Equal(t, linkPath, updated.Path)
1576+
1577+
composeBytes, readErr := os.ReadFile(filepath.Join(targetPath, "compose.yaml"))
1578+
require.NoError(t, readErr)
1579+
assert.Equal(t, updatedCompose, string(composeBytes))
1580+
1581+
envBytes, readErr := os.ReadFile(filepath.Join(targetPath, ".env"))
1582+
require.NoError(t, readErr)
1583+
assert.Equal(t, updatedEnv, string(envBytes))
1584+
}
1585+
1586+
func createComposeProjectDir(t *testing.T, root, name string) string {
1587+
t.Helper()
1588+
1589+
projectPath := filepath.Join(root, name)
1590+
require.NoError(t, os.MkdirAll(projectPath, 0o755))
1591+
require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte("services:\n app:\n image: nginx:alpine\n"), 0o644))
1592+
1593+
return projectPath
1594+
}
1595+
14241596
//go:fix inline
14251597
func ptr(v string) *string {
14261598
return new(v)

backend/internal/services/settings_service.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ func (s *SettingsService) LoadDatabaseSettings(ctx context.Context) (err error)
8787
func (s *SettingsService) getDefaultSettings() *models.Settings {
8888
return &models.Settings{
8989
ProjectsDirectory: models.SettingVariable{Value: "/app/data/projects"},
90+
FollowProjectSymlinks: models.SettingVariable{Value: "false"},
9091
DiskUsagePath: models.SettingVariable{Value: "/app/data/projects"},
9192
AutoUpdate: models.SettingVariable{Value: "false"},
9293
AutoUpdateInterval: models.SettingVariable{Value: "0 0 0 * * *"},
@@ -510,7 +511,9 @@ func (s *SettingsService) UpdateSettings(ctx context.Context, updates settings.U
510511
if changedAutoHeal && s.OnAutoHealSettingsChanged != nil {
511512
s.OnAutoHealSettingsChanged(ctx)
512513
}
513-
if slices.ContainsFunc(valuesToUpdate, func(sv models.SettingVariable) bool { return sv.Key == "projectsDirectory" }) && s.OnProjectsDirectoryChanged != nil {
514+
if slices.ContainsFunc(valuesToUpdate, func(sv models.SettingVariable) bool {
515+
return sv.Key == "projectsDirectory" || sv.Key == "followProjectSymlinks"
516+
}) && s.OnProjectsDirectoryChanged != nil {
514517
s.OnProjectsDirectoryChanged(ctx)
515518
}
516519
if len(changedTimeouts) > 0 && s.OnTimeoutSettingsChanged != nil {

backend/internal/services/settings_service_test.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,14 @@ func TestSettingsService_EnsureDefaultSettings_Idempotent(t *testing.T) {
4444
require.Equal(t, count1, count2)
4545

4646
// Spot-check core and automation defaults exist with correct values
47-
for _, key := range []string{"authLocalEnabled", "projectsDirectory", "autoUpdateExcludedContainers", "vulnerabilityScanEnabled", "vulnerabilityScanInterval", "trivyNetwork", "trivySecurityOpts", "trivyPrivileged", "trivyPreserveCacheOnVolumePrune", "trivyResourceLimitsEnabled", "trivyCpuLimit", "trivyMemoryLimitMb", "trivyConcurrentScanContainers"} {
47+
for _, key := range []string{"authLocalEnabled", "projectsDirectory", "followProjectSymlinks", "autoUpdateExcludedContainers", "vulnerabilityScanEnabled", "vulnerabilityScanInterval", "trivyNetwork", "trivySecurityOpts", "trivyPrivileged", "trivyPreserveCacheOnVolumePrune", "trivyResourceLimitsEnabled", "trivyCpuLimit", "trivyMemoryLimitMb", "trivyConcurrentScanContainers"} {
4848
var sv models.SettingVariable
4949
err := svc.db.WithContext(ctx).Where("key = ?", key).First(&sv).Error
5050
require.NoErrorf(t, err, "missing default key %s", key)
5151

5252
switch key {
53+
case "followProjectSymlinks":
54+
require.Equal(t, "false", sv.Value)
5355
case "autoUpdateExcludedContainers":
5456
require.Equal(t, "", sv.Value)
5557
case "vulnerabilityScanEnabled":
@@ -195,6 +197,24 @@ func TestSettingsService_GetSettings_EnvOverride_TrivyNetwork(t *testing.T) {
195197
require.Equal(t, "arcane-external", settings2.TrivyNetwork.Value)
196198
}
197199

200+
func TestSettingsService_GetSettings_EnvOverride_FollowProjectSymlinks(t *testing.T) {
201+
ctx := context.Background()
202+
db := setupSettingsTestDB(t)
203+
204+
svc, err := NewSettingsService(ctx, db)
205+
require.NoError(t, err)
206+
require.NoError(t, svc.EnsureDefaultSettings(ctx))
207+
208+
settings1, err := svc.GetSettings(ctx)
209+
require.NoError(t, err)
210+
require.False(t, settings1.FollowProjectSymlinks.IsTrue())
211+
212+
t.Setenv("FOLLOW_PROJECT_SYMLINKS", "true")
213+
settings2, err := svc.GetSettings(ctx)
214+
require.NoError(t, err)
215+
require.True(t, settings2.FollowProjectSymlinks.IsTrue())
216+
}
217+
198218
func TestSettingsService_GetSettings_EnvOverride_TrivyRuntimeSecurity(t *testing.T) {
199219
ctx := context.Background()
200220
db := setupSettingsTestDB(t)

0 commit comments

Comments
 (0)