Skip to content

Commit f277219

Browse files
committed
fix: support custom canonical compose filenames
1 parent 0bbf8dc commit f277219

File tree

11 files changed

+822
-206
lines changed

11 files changed

+822
-206
lines changed

backend/internal/bootstrap/bootstrap.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ func initializeStartupState(appCtx context.Context, cfg *config.Config, appServi
137137
appServices.GitOpsSync.ListSyncIntervalsRaw,
138138
appServices.GitOpsSync.UpdateSyncIntervalMinutes,
139139
)
140+
if err := appServices.GitOpsSync.ReconcileDirectorySyncProjectsOnStartup(appCtx); err != nil {
141+
slog.WarnContext(appCtx, "Failed to reconcile directory GitOps projects on startup", "error", err)
142+
}
140143
}
141144

142145
if err := appServices.Settings.NormalizeProjectsDirectory(appCtx, cfg.ProjectsDirectory); err != nil {

backend/internal/services/gitops_sync_service.go

Lines changed: 290 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
716747
func (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

850881
func (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.
10811334
func (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

Comments
 (0)