feat: deployment rollback to previous image
- Add previous_image_id column to apps table (migration 006)
- Save current image as previous before deploying new one
- POST /apps/{id}/rollback endpoint with handler
- Rollback stops current container, starts previous image
- Creates deployment record for rollback operations
- Rollback button in app detail UI (only when previous image exists)
- Add btn-warning CSS class for rollback button styling
fixes #71
This commit is contained in:
@@ -49,6 +49,8 @@ var (
|
||||
ErrBuildTimeout = errors.New("build timeout exceeded")
|
||||
// ErrDeployTimeout indicates the deploy phase exceeded the timeout.
|
||||
ErrDeployTimeout = errors.New("deploy timeout exceeded")
|
||||
// ErrNoPreviousImage indicates there is no previous image to rollback to.
|
||||
ErrNoPreviousImage = errors.New("no previous image available for rollback")
|
||||
)
|
||||
|
||||
// logFlushInterval is how often to flush buffered logs to the database.
|
||||
@@ -359,6 +361,107 @@ func (svc *Service) Deploy(
|
||||
return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment)
|
||||
}
|
||||
|
||||
// Rollback rolls back an app to its previous image.
|
||||
// It stops the current container, starts a new one with the previous image,
|
||||
// and creates a deployment record for the rollback.
|
||||
func (svc *Service) Rollback(ctx context.Context, app *models.App) error {
|
||||
if !app.PreviousImageID.Valid || app.PreviousImageID.String == "" {
|
||||
return ErrNoPreviousImage
|
||||
}
|
||||
|
||||
// Acquire per-app deployment lock
|
||||
if !svc.tryLockApp(app.ID) {
|
||||
return ErrDeploymentInProgress
|
||||
}
|
||||
defer svc.unlockApp(app.ID)
|
||||
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
|
||||
deployment, err := svc.createRollbackDeployment(bgCtx, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return svc.executeRollback(ctx, bgCtx, app, deployment)
|
||||
}
|
||||
|
||||
// createRollbackDeployment creates a deployment record for a rollback operation.
|
||||
func (svc *Service) createRollbackDeployment(
|
||||
ctx context.Context,
|
||||
app *models.App,
|
||||
) (*models.Deployment, error) {
|
||||
deployment := models.NewDeployment(svc.db)
|
||||
deployment.AppID = app.ID
|
||||
deployment.Status = models.DeploymentStatusDeploying
|
||||
deployment.ImageID = sql.NullString{String: app.PreviousImageID.String, Valid: true}
|
||||
|
||||
saveErr := deployment.Save(ctx)
|
||||
if saveErr != nil {
|
||||
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(ctx, "Rolling back to previous image: "+app.PreviousImageID.String)
|
||||
|
||||
return deployment, nil
|
||||
}
|
||||
|
||||
// executeRollback performs the container swap for a rollback.
|
||||
func (svc *Service) executeRollback(
|
||||
ctx context.Context,
|
||||
bgCtx context.Context,
|
||||
app *models.App,
|
||||
deployment *models.Deployment,
|
||||
) error {
|
||||
previousImageID := app.PreviousImageID.String
|
||||
|
||||
svc.removeOldContainer(ctx, app, deployment)
|
||||
|
||||
rollbackOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
|
||||
if err != nil {
|
||||
svc.failDeployment(bgCtx, app, deployment, err)
|
||||
|
||||
return fmt.Errorf("failed to build container options: %w", err)
|
||||
}
|
||||
|
||||
rollbackOpts.Image = previousImageID
|
||||
|
||||
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
|
||||
if err != nil {
|
||||
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
|
||||
|
||||
return fmt.Errorf("failed to create rollback container: %w", err)
|
||||
}
|
||||
|
||||
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
|
||||
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
|
||||
|
||||
startErr := svc.docker.StartContainer(ctx, containerID)
|
||||
if startErr != nil {
|
||||
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr))
|
||||
|
||||
return fmt.Errorf("failed to start rollback container: %w", startErr)
|
||||
}
|
||||
|
||||
_ = deployment.AppendLog(bgCtx, "Rollback container started")
|
||||
|
||||
currentImageID := app.ImageID
|
||||
app.ImageID = sql.NullString{String: previousImageID, Valid: true}
|
||||
app.PreviousImageID = currentImageID
|
||||
app.Status = models.AppStatusRunning
|
||||
|
||||
saveErr := app.Save(bgCtx)
|
||||
if saveErr != nil {
|
||||
return fmt.Errorf("failed to update app after rollback: %w", saveErr)
|
||||
}
|
||||
|
||||
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusSuccess)
|
||||
_ = deployment.AppendLog(bgCtx, "Rollback complete")
|
||||
|
||||
svc.log.Info("rollback completed", "app", app.Name, "image", previousImageID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runBuildAndDeploy executes the build and deploy phases, handling cancellation.
|
||||
func (svc *Service) runBuildAndDeploy(
|
||||
deployCtx context.Context,
|
||||
@@ -390,6 +493,11 @@ func (svc *Service) runBuildAndDeploy(
|
||||
return err
|
||||
}
|
||||
|
||||
// Save current image as previous before updating to new one
|
||||
if app.ImageID.Valid && app.ImageID.String != "" {
|
||||
app.PreviousImageID = app.ImageID
|
||||
}
|
||||
|
||||
err = svc.updateAppRunning(bgCtx, app, imageID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user