feat(compose): implement docker compose rollbacks#3879
feat(compose): implement docker compose rollbacks#3879freddysae0 wants to merge 2 commits intoDokploy:canaryfrom
Conversation
| const rollbackComposeApplication = async ( | ||
| composeId: string, | ||
| fullContext: Compose & { commitHash?: string }, | ||
| ) => { | ||
| await updateCompose(composeId, { | ||
| composeFile: fullContext.composeFile, | ||
| env: fullContext.env, | ||
| // Provide everything else correctly if needed, but the primary drivers are the files. | ||
| }); | ||
|
|
||
| // For git repos, we need a way to pass the commitHash to the deployment function so it checks it out. | ||
| // We'll call deployCompose with an optional commitHash override. | ||
| const { deployCompose } = await import("./compose"); | ||
| await deployCompose({ | ||
| composeId, | ||
| titleLog: "Rollback deployment", | ||
| descriptionLog: "Rolled back to a previous configuration", | ||
| commitHash: fullContext.commitHash, | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Each rollback triggers a new rollback entry — unbounded chain
rollbackComposeApplication calls deployCompose(), which on success calls createRollback() at lines 289–294 of compose.ts. This means every rollback operation creates a new rollback snapshot in the database, which in turn creates another rollback entry, ad infinitum as users perform successive rollbacks.
Unlike rollbackApplication (which directly updates the Docker service without going through the full deployment pipeline), this path:
- Creates a new deployment record
- Creates a new rollback record pointing to that deployment
- Populates the deployments list with rollback-triggered entries, making the history hard to read
Consider either:
- Passing a flag like
isRollback: truetodeployComposeto skip callingcreateRollbackwhen the deployment is itself a rollback, or - Implementing
rollbackComposeApplicationsimilarly torollbackApplication— applying the Docker Compose state directly without going throughdeployCompose.
| const organizationId = | ||
| currentRollback?.deployment?.application?.environment?.project | ||
| .organizationId !== ctx.session.activeOrganizationId | ||
| ) { | ||
| ?.organizationId || | ||
| currentRollback?.deployment?.compose?.environment?.project | ||
| ?.organizationId; | ||
|
|
||
| if (organizationId !== ctx.session.activeOrganizationId) { |
There was a problem hiding this comment.
Authorization bypass when neither applicationId nor composeId is set
organizationId is resolved via ||. If both the application and compose paths return null/undefined (e.g., due to corrupted or partial data), organizationId becomes undefined. If ctx.session.activeOrganizationId is also undefined or null, then undefined !== undefined evaluates to false and the check passes — granting unauthorized access.
The original code raised a TypeError in this scenario (because it accessed .organizationId without optional chaining on the final property), which effectively blocked the action. The new code silently allows it when both sides are nullish.
Consider adding an explicit guard:
if (!organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rollback this deployment",
});
}
if (organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rollback this deployment",
});
}
Additional Comments (2)
When The same fix applied to Note: The same issue exists in
The // Current (broken for compose):
if (rollback?.image) {
try {
// ...delete image...
await db.delete(rollbacks).where(...); // ← only reached when image is non-null
} catch (error) { ... }
}
return rollback; // ← always reached, even when nothing was deletedThe if (rollback?.image) {
try {
const deployment = await findDeploymentById(rollback.deploymentId);
if (deployment?.applicationId) {
const application = await findApplicationById(deployment.applicationId);
await deleteRollbackImage(rollback.image, application.serverId);
}
} catch (error) {
console.error(error);
}
}
await db
.delete(rollbacks)
.where(eq(rollbacks.rollbackId, rollbackId))
.returning()
.then((res) => res[0]);
return rollback; |
- Add commitHash support to GitHub and Bitbucket clone functions, performing a full clone + git checkout when a hash is provided - Fix compose rollback deletion by moving db.delete() outside the image guard so records with image: null are also removed - Prevent recursive rollback entries by adding isRollback flag to deployCompose(), skipping createRollback() during rollback ops - Fix authorization bypass by using nullish coalescing and adding an explicit organizationId null check in the rollbacks router
What is this PR about?
This PR implements the ability to perform Rollbacks on Docker Compose services. Previously, the platform only supported rolling back Dockerfile-based deployments.
This change:
Checklist
Before submitting this PR, please make sure that:
canarybranch.Issues related (if applicable)
Docker-compose: ability to rollback to a previous deployment #2771
Screenshots (if applicable)
Greptile Summary
This PR implements Docker Compose rollbacks by extending the rollback schema with a
commitHash/composeFilecontext, threadingcommitHashthrough the git providers for a full-clone-and-checkout flow, and enabling the rollback UI button for compose-type deployments. The overall approach is sound but there are several correctness bugs that will cause rollbacks to fail silently or create unexpected side effects.Issues found:
GitHub and Bitbucket providers not updated —
cloneGithubRepository(github.ts:170) andcloneBitbucketRepository(bitbucket.ts:128) were not givencommitHashsupport. Becausegit clone --depth 1 --branchis always used, any rollback for a GitHub- or Bitbucket-sourced compose service will silently deploy the latest branch HEAD instead of the saved commit.gitlab.tsandgitea.tswere correctly updated;github.tsandbitbucket.tsneed the same treatment.Compose rollbacks cannot be deleted — In
removeRollbackById(rollbacks.ts:149–176), thedb.deletecall sits insideif (rollback?.image). Compose rollbacks haveimage: null, so this block is never entered and the record is never deleted from the database. The delete call must be moved outside the image guard.Each rollback creates a new rollback entry —
rollbackComposeApplicationcallsdeployCompose, which on a successful deployment callscreateRollback. This means every rollback produces a new rollback snapshot, bloating the history with rollback-of-rollback entries and making the deployment list confusing.Authorization bypass risk — In the rollback router, resolving
organizationIdvia||means that if neither theapplicationnor thecomposepath resolves (e.g., due to data inconsistency),organizationIdisundefined. Whenctx.session.activeOrganizationIdis also nullish,undefined !== undefinedisfalseand the auth guard is silently bypassed.Confidence Score: 1/5
commitHashparameter so rollbacks for those source types always deploy HEAD, theremoveRollbackByIdfunction never deletes compose rollback records from the database, and each rollback operation creates an ever-growing chain of new rollback entries by callingdeployCompose→createRollbackrecursively. The auth guard also has a potential nullish-bypass edge case.packages/server/src/utils/providers/github.tsandbitbucket.ts(missingcommitHashsupport),packages/server/src/services/rollbacks.ts(removeRollbackByIddeletion bug and recursive rollback creation), andapps/dokploy/server/api/routers/rollbacks.ts(auth guard).Last reviewed commit: f4a4734
(2/5) Greptile learns from your feedback when you react with thumbs up/down!