@@ -713,6 +713,37 @@ func (s *GitOpsSyncService) SyncAllEnabled(ctx context.Context) error {
713713 return nil
714714}
715715
716+ func (s * GitOpsSyncService ) ReconcileDirectorySyncProjectsOnStartup (ctx context.Context ) error {
717+ var syncs []models.GitOpsSync
718+ if err := s .db .WithContext (ctx ).
719+ Where ("sync_directory = ?" , true ).
720+ Find (& syncs ).Error ; err != nil {
721+ return fmt .Errorf ("failed to list directory syncs for startup reconciliation: %w" , err )
722+ }
723+
724+ for i := range syncs {
725+ originalProjectID := ""
726+ if syncs [i ].ProjectID != nil {
727+ originalProjectID = * syncs [i ].ProjectID
728+ }
729+
730+ project , err := s .getDirectorySyncProjectInternal (ctx , & syncs [i ])
731+ if err != nil {
732+ slog .WarnContext (ctx , "Failed to reconcile directory GitOps sync on startup" , "syncId" , syncs [i ].ID , "error" , err )
733+ continue
734+ }
735+ if project == nil {
736+ continue
737+ }
738+
739+ if originalProjectID != project .ID {
740+ slog .InfoContext (ctx , "Reconciled directory GitOps sync on startup" , "syncId" , syncs [i ].ID , "projectId" , project .ID )
741+ }
742+ }
743+
744+ return nil
745+ }
746+
716747func (s * GitOpsSyncService ) BrowseFiles (ctx context.Context , environmentID , id string , path string ) (* gitops.BrowseResponse , error ) {
717748 browseCtx , cancel := context .WithTimeout (ctx , defaultGitSyncTimeout )
718749 defer cancel ()
@@ -849,12 +880,16 @@ func (s *GitOpsSyncService) createProjectForSyncInternal(ctx context.Context, sy
849880
850881func (s * GitOpsSyncService ) getOrCreateProjectInternal (ctx context.Context , sync * models.GitOpsSync , id string , composeContent string , envContent * string , result * gitops.SyncResult , actor models.User ) (* models.Project , error ) {
851882 var project * models.Project
852- var err error
853883
854884 if sync .ProjectID != nil && * sync .ProjectID != "" {
855- project , err = s .projectService .GetProjectFromDatabaseByID (ctx , * sync .ProjectID )
856- if err != nil {
857- slog .WarnContext (ctx , "Existing project not found, will create new one" , "projectId" , * sync .ProjectID , "error" , err )
885+ var found bool
886+ var lookupErr error
887+ project , found , lookupErr = s .lookupProjectByIDInternal (ctx , * sync .ProjectID )
888+ if lookupErr != nil {
889+ return nil , s .failSync (ctx , id , result , sync , actor , "Failed to load existing project" , lookupErr .Error ())
890+ }
891+ if ! found {
892+ slog .WarnContext (ctx , "Existing project not found, will create new one" , "projectId" , * sync .ProjectID )
858893 project = nil
859894 }
860895 }
@@ -1076,24 +1111,269 @@ func (s *GitOpsSyncService) stageDirectorySyncInternal(ctx context.Context, sync
10761111 }, nil
10771112}
10781113
1114+ func (s * GitOpsSyncService ) lookupProjectByIDInternal (ctx context.Context , projectID string ) (* models.Project , bool , error ) {
1115+ var project models.Project
1116+ if err := s .db .WithContext (ctx ).Where ("id = ?" , projectID ).First (& project ).Error ; err != nil {
1117+ if errors .Is (err , gorm .ErrRecordNotFound ) {
1118+ return nil , false , nil
1119+ }
1120+ return nil , false , fmt .Errorf ("failed to get project %s: %w" , projectID , err )
1121+ }
1122+
1123+ return & project , true , nil
1124+ }
1125+
1126+ func (s * GitOpsSyncService ) lookupProjectByPathInternal (ctx context.Context , projectPath string ) (* models.Project , bool , error ) {
1127+ var project models.Project
1128+ if err := s .db .WithContext (ctx ).Where ("path = ?" , projectPath ).First (& project ).Error ; err != nil {
1129+ if errors .Is (err , gorm .ErrRecordNotFound ) {
1130+ return nil , false , nil
1131+ }
1132+ return nil , false , fmt .Errorf ("failed to get project by path %s: %w" , projectPath , err )
1133+ }
1134+
1135+ return & project , true , nil
1136+ }
1137+
1138+ func (s * GitOpsSyncService ) ensureDirectorySyncProjectLinkedInternal (ctx context.Context , sync * models.GitOpsSync , project * models.Project ) error {
1139+ if sync == nil || project == nil {
1140+ return nil
1141+ }
1142+
1143+ if project .GitOpsManagedBy != nil && * project .GitOpsManagedBy != "" && * project .GitOpsManagedBy != sync .ID {
1144+ return fmt .Errorf ("project %s is already managed by a different GitOps sync" , project .ID )
1145+ }
1146+
1147+ if sync .ProjectID != nil && * sync .ProjectID == project .ID && project .GitOpsManagedBy != nil && * project .GitOpsManagedBy == sync .ID {
1148+ s .projectService .cacheComposeProjectIDInternal (normalizeComposeProjectName (project .Name ), project .ID )
1149+ return nil
1150+ }
1151+
1152+ updatesSync := map [string ]any {}
1153+ updatesProject := map [string ]any {}
1154+ if sync .ProjectID == nil || * sync .ProjectID != project .ID {
1155+ updatesSync ["project_id" ] = project .ID
1156+ }
1157+ if project .GitOpsManagedBy == nil || * project .GitOpsManagedBy != sync .ID {
1158+ updatesProject ["gitops_managed_by" ] = sync .ID
1159+ }
1160+
1161+ if len (updatesSync ) == 0 && len (updatesProject ) == 0 {
1162+ s .projectService .cacheComposeProjectIDInternal (normalizeComposeProjectName (project .Name ), project .ID )
1163+ return nil
1164+ }
1165+
1166+ if err := s .db .WithContext (ctx ).Transaction (func (tx * gorm.DB ) error {
1167+ if len (updatesSync ) > 0 {
1168+ if err := tx .Model (& models.GitOpsSync {}).Where ("id = ?" , sync .ID ).Updates (updatesSync ).Error ; err != nil {
1169+ return fmt .Errorf ("failed to relink GitOps sync %s: %w" , sync .ID , err )
1170+ }
1171+ }
1172+ if len (updatesProject ) > 0 {
1173+ if err := tx .Model (& models.Project {}).Where ("id = ?" , project .ID ).Updates (updatesProject ).Error ; err != nil {
1174+ return fmt .Errorf ("failed to relink project %s to GitOps sync %s: %w" , project .ID , sync .ID , err )
1175+ }
1176+ }
1177+ return nil
1178+ }); err != nil {
1179+ return err
1180+ }
1181+
1182+ sync .ProjectID = & project .ID
1183+ project .GitOpsManagedBy = & sync .ID
1184+ s .projectService .cacheComposeProjectIDInternal (normalizeComposeProjectName (project .Name ), project .ID )
1185+
1186+ return nil
1187+ }
1188+
1189+ func (s * GitOpsSyncService ) findRecoverableManagedProjectInternal (ctx context.Context , sync * models.GitOpsSync ) (* models.Project , error ) {
1190+ var managedProjects []models.Project
1191+ if err := s .db .WithContext (ctx ).
1192+ Where ("gitops_managed_by = ?" , sync .ID ).
1193+ Find (& managedProjects ).Error ; err != nil {
1194+ return nil , fmt .Errorf ("failed to list GitOps-managed projects for sync %s: %w" , sync .ID , err )
1195+ }
1196+
1197+ matches := make ([]models.Project , 0 , len (managedProjects ))
1198+ for i := range managedProjects {
1199+ project := managedProjects [i ]
1200+ if err := s .projectService .ensureProjectPathUnderRoot (ctx , & project , true ); err != nil {
1201+ return nil , err
1202+ }
1203+ if _ , err := s .projectService .resolveProjectComposeFileInternal (ctx , & project ); err != nil {
1204+ if errors .Is (err , errProjectComposeFileNotFound ) {
1205+ continue
1206+ }
1207+ return nil , err
1208+ }
1209+ matches = append (matches , project )
1210+ }
1211+
1212+ switch len (matches ) {
1213+ case 0 :
1214+ return nil , nil
1215+ case 1 :
1216+ return & matches [0 ], nil
1217+ default :
1218+ return nil , fmt .Errorf ("multiple GitOps-managed projects match sync %s; refusing automatic relink" , sync .ID )
1219+ }
1220+ }
1221+
1222+ func (s * GitOpsSyncService ) findUniqueProjectDirectoryCandidateInternal (ctx context.Context , sync * models.GitOpsSync ) (string , error ) {
1223+ projectsDir , err := s .projectService .getProjectsDirectoryInternal (ctx )
1224+ if err != nil {
1225+ return "" , err
1226+ }
1227+
1228+ entries , err := os .ReadDir (projectsDir )
1229+ if err != nil {
1230+ return "" , fmt .Errorf ("failed to list projects directory %s: %w" , projectsDir , err )
1231+ }
1232+
1233+ composeFileName := strings .TrimSpace (filepath .Base (sync .ComposePath ))
1234+ if composeFileName == "" || composeFileName == "." {
1235+ return "" , nil
1236+ }
1237+
1238+ prefix := projects .SanitizeProjectName (sync .ProjectName )
1239+ matches := make ([]string , 0 , 1 )
1240+ for _ , entry := range entries {
1241+ candidatePath := filepath .Join (projectsDir , entry .Name ())
1242+ if ! projects .IsProjectDirectoryEntry (entry , candidatePath , false ) {
1243+ continue
1244+ }
1245+ if prefix != "" && entry .Name () != prefix && ! strings .HasPrefix (entry .Name (), prefix + "-" ) {
1246+ continue
1247+ }
1248+
1249+ composePath := filepath .Join (candidatePath , composeFileName )
1250+ if info , statErr := os .Stat (composePath ); statErr == nil {
1251+ if ! info .IsDir () {
1252+ matches = append (matches , candidatePath )
1253+ }
1254+ } else if ! os .IsNotExist (statErr ) {
1255+ return "" , fmt .Errorf ("failed to inspect recovery candidate %s: %w" , composePath , statErr )
1256+ }
1257+ }
1258+
1259+ switch len (matches ) {
1260+ case 0 :
1261+ return "" , nil
1262+ case 1 :
1263+ return matches [0 ], nil
1264+ default :
1265+ return "" , fmt .Errorf ("multiple project directories match sync %s; refusing automatic relink" , sync .ID )
1266+ }
1267+ }
1268+
1269+ func (s * GitOpsSyncService ) createRecoveredProjectFromDirectoryInternal (ctx context.Context , sync * models.GitOpsSync , projectPath string ) (* models.Project , error ) {
1270+ dirName := filepath .Base (projectPath )
1271+ reason := "Project recovered from existing GitOps-managed directory"
1272+ project := & models.Project {
1273+ Name : sync .ProjectName ,
1274+ DirName : & dirName ,
1275+ Path : projectPath ,
1276+ Status : models .ProjectStatusUnknown ,
1277+ StatusReason : & reason ,
1278+ ServiceCount : 0 ,
1279+ RunningCount : 0 ,
1280+ GitOpsManagedBy : & sync .ID ,
1281+ }
1282+
1283+ if serviceCount , err := s .projectService .countServicesFromCompose (ctx , * project ); err == nil {
1284+ project .ServiceCount = serviceCount
1285+ } else {
1286+ slog .WarnContext (ctx , "Failed to count services while recovering GitOps project" , "syncId" , sync .ID , "path" , projectPath , "error" , err )
1287+ }
1288+
1289+ if err := s .db .WithContext (ctx ).Transaction (func (tx * gorm.DB ) error {
1290+ if err := tx .Create (project ).Error ; err != nil {
1291+ return fmt .Errorf ("failed to create recovered project for sync %s: %w" , sync .ID , err )
1292+ }
1293+
1294+ if err := tx .Model (& models.GitOpsSync {}).Where ("id = ?" , sync .ID ).Update ("project_id" , project .ID ).Error ; err != nil {
1295+ return fmt .Errorf ("failed to relink sync %s to recovered project %s: %w" , sync .ID , project .ID , err )
1296+ }
1297+
1298+ return nil
1299+ }); err != nil {
1300+ return nil , err
1301+ }
1302+
1303+ sync .ProjectID = & project .ID
1304+ s .projectService .cacheComposeProjectIDInternal (normalizeComposeProjectName (project .Name ), project .ID )
1305+
1306+ return project , nil
1307+ }
1308+
1309+ func (s * GitOpsSyncService ) recoverProjectFromDirectoryCandidateInternal (ctx context.Context , sync * models.GitOpsSync ) (* models.Project , error ) {
1310+ projectPath , err := s .findUniqueProjectDirectoryCandidateInternal (ctx , sync )
1311+ if err != nil || projectPath == "" {
1312+ return nil , err
1313+ }
1314+
1315+ project , found , err := s .lookupProjectByPathInternal (ctx , projectPath )
1316+ if err != nil {
1317+ return nil , err
1318+ }
1319+ if found {
1320+ if err := s .projectService .ensureProjectPathUnderRoot (ctx , project , true ); err != nil {
1321+ return nil , err
1322+ }
1323+ if err := s .ensureDirectorySyncProjectLinkedInternal (ctx , sync , project ); err != nil {
1324+ return nil , err
1325+ }
1326+ return project , nil
1327+ }
1328+
1329+ return s .createRecoveredProjectFromDirectoryInternal (ctx , sync , projectPath )
1330+ }
1331+
10791332// getDirectorySyncProjectInternal resolves the linked project for a sync when one
10801333// exists, while tolerating deleted/stale project references.
10811334func (s * GitOpsSyncService ) getDirectorySyncProjectInternal (ctx context.Context , sync * models.GitOpsSync ) (* models.Project , error ) {
1082- if sync . ProjectID == nil || * sync . ProjectID == "" {
1335+ if sync == nil {
10831336 return nil , nil
10841337 }
10851338
1086- project , err := s .projectService .GetProjectFromDatabaseByID (ctx , * sync .ProjectID )
1339+ if sync .ProjectID != nil && * sync .ProjectID != "" {
1340+ project , found , err := s .lookupProjectByIDInternal (ctx , * sync .ProjectID )
1341+ if err != nil {
1342+ return nil , err
1343+ }
1344+ if found {
1345+ if err := s .projectService .ensureProjectPathUnderRoot (ctx , project , true ); err != nil {
1346+ return nil , err
1347+ }
1348+ if err := s .ensureDirectorySyncProjectLinkedInternal (ctx , sync , project ); err != nil {
1349+ return nil , err
1350+ }
1351+ return project , nil
1352+ }
1353+
1354+ slog .WarnContext (ctx , "Existing project not found, attempting recovery" , "projectId" , * sync .ProjectID , "syncId" , sync .ID )
1355+ }
1356+
1357+ project , err := s .findRecoverableManagedProjectInternal (ctx , sync )
10871358 if err != nil {
1088- slog .WarnContext (ctx , "Existing project not found, will create new one" , "projectId" , * sync .ProjectID , "error" , err )
1089- return nil , nil
1359+ return nil , err
1360+ }
1361+ if project != nil {
1362+ if err := s .ensureDirectorySyncProjectLinkedInternal (ctx , sync , project ); err != nil {
1363+ return nil , err
1364+ }
1365+ return project , nil
10901366 }
10911367
1092- if err := s .projectService .ensureProjectPathUnderRoot (ctx , project , false ); err != nil {
1368+ project , err = s .recoverProjectFromDirectoryCandidateInternal (ctx , sync )
1369+ if err != nil {
10931370 return nil , err
10941371 }
1372+ if project != nil {
1373+ return project , nil
1374+ }
10951375
1096- return project , nil
1376+ return nil , nil
10971377}
10981378
10991379// validateDirectorySyncStageInternal loads the staged compose project using the real
0 commit comments