Skip to content

feat(migrations): Add managed migration operations - #230

Merged
nfebe merged 9 commits into
mainfrom
feat/migration-operations
Sep 27, 2026
Merged

nfebe merged 9 commits into
mainfrom
feat/migration-operations

Conversation

@nfebe

@nfebe nfebe commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Make migrations operable without editing server files or using SSH. Backup copies must verify successfully before they count as protected, and retirement stays blocked until cutover checks pass.

Hostname additions and updates now validate conflicting entries and save reliably through the API,
including installations without a proxy orchestrator.
Certificate issuance and renewal can read the shared challenge webroot after an HTTPS redirect
without application-specific mounts.
Each deployment can publish backups only to its selected stores. Existing policies continue
publishing to every enabled destination until a selection is saved.
Recovery sets now carry an archive checksum and remote copies are read back before a backup is
reported as protected.
Recovery copies can now start under a new deployment name with published ports and external network
access disabled. Restore options sent through the API are now honored.
Operators can manage retention, exclusions, storage thresholds, schedules, failures, and cleanup
previews from one deployment policy.
Migration plans now persist source inventory, transfer and synchronization progress, DNS
propagation, cutover timing, and retirement blockers.
Existing deployments can now receive managed, shared, existing, or external database connections
without manual server edits.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/api/server.go Outdated
protected.POST("/deployments/:name/backup-cleanup", s.authMiddleware.RequirePermission(auth.PermBackupsDelete), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelAdmin), s.cleanupDeploymentBackups)
protected.GET("/deployments/:name/migration", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentMigration)
protected.PUT("/deployments/:name/migration", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.updateDeploymentMigration)
protected.POST("/deployments/:name/migration/check-dns", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.checkDeploymentMigrationDNS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkDeploymentMigrationDNS persists the refreshed plan through SaveMetadata, yet this route only requires deployments:read, so a read-only principal mutates stored deployment metadata and the retirement gate. Require PermDeploymentsWrite/AccessLevelWrite here, or make the handler stop persisting.

Comment thread internal/api/server.go Outdated
protected.POST("/deployments/:name/backup-cleanup", s.authMiddleware.RequirePermission(auth.PermBackupsDelete), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelAdmin), s.cleanupDeploymentBackups)
protected.GET("/deployments/:name/migration", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentMigration)
protected.PUT("/deployments/:name/migration", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.updateDeploymentMigration)
protected.POST("/deployments/:name/migration/check-dns", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.checkDeploymentMigrationDNS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route only requires deployments:read, but checkDeploymentMigrationDNS writes DNS results into the plan and persists it with SaveMetadata. A read-scoped token therefore mutates stored migration state, contradicting the read permission also declared in openapi.json. Require deployments:write or stop persisting.

Comment thread internal/api/backup_handlers.go Outdated
c.JSON(http.StatusForbidden, gin.H{"error": "Deployment write permission required"})
return
}
if s.authManager != nil && actor != nil && actor.User != nil && actor.Role != auth.RoleAdmin {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The grant runs before RestoreBackup, so a caller holding backups:write can grant themselves admin on any existing deployment. The restore then fails with "deployment already exists", but the ACL grant persists. Reject an existing target here, or move the grant after a successful restore.

Suggested change
if s.authManager != nil && actor != nil && actor.User != nil && actor.Role != auth.RoleAdmin {
if _, lookupErr := s.manager.GetDeployment(targetDeployment); lookupErr == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already exists"})
return
}
if s.authManager != nil && actor != nil && actor.User != nil && actor.Role != auth.RoleAdmin {
if err := s.authManager.AssignDeployment(actor.User.ID, targetDeployment, auth.AccessLevelAdmin, actor.User.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to grant access to restored deployment"})
return
}
}

@sourceant

sourceant Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Code Review Summary

Adds managed migration, backup policy, database attach, and cleanup preview APIs so deployments can be operated without editing server files or using SSH. Migration retirement stays blocked until inventory, transfers, DNS propagation, a recent sync, and cutover are recorded. Backups record SHA-256 checksums and count remote copies as protected only when checksums match, while isolated restores require a new deployment name.

🚀 Key Improvements

  • Migration retirement is gated on inventory, transfer, DNS, sync and cutover checks.
  • Remote backup copies are verified against local SHA-256 checksums before being reported as protected.
  • Cleanup gains a non-destructive preview; isolated restores reject existing targets and strip ports and host bindings.

Recovery access is granted only after a successful isolated restore.
Restored services no longer reuse production storage or project identity.

Database attachment preserves imported environment values.
Backup cleanup follows deployment policy, and DNS checks require write access.
@nfebe
nfebe force-pushed the feat/migration-operations branch from 4ec62b1 to abc51d6 Compare September 27, 2026 20:24

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

@nfebe
nfebe merged commit fc17a16 into main Sep 27, 2026
6 checks passed
@nfebe
nfebe deleted the feat/migration-operations branch September 27, 2026 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant