@@ -2,6 +2,7 @@ import { runRemoteCommandForConfig } from '../runner.js';
22import { ReleaseManager } from '../../domain/release/manager.js' ;
33import { HealthCheckService } from '../../services/health.service.js' ;
44import { CaddyService } from '../../services/caddy.service.js' ;
5+ import { SshConnection } from '../../infrastructure/ssh/connection.js' ;
56import { ui } from '../ui.js' ;
67import { confirm } from '../prompt.js' ;
78import { loadConfig } from '../../config/loader.js' ;
@@ -14,13 +15,26 @@ import {
1415 portFor ,
1516 coloredWebName ,
1617} from '../../domain/deploy/blue-green.js' ;
18+ import { rollFleet , type FleetEvent } from '../../domain/deploy/fleet.js' ;
1719import type { RemoteExecutor } from '../../domain/remote/executor.js' ;
1820import type { ShipnodeConfig , ShipnodeApp } from '../../shared/types.js' ;
19- import { configForAppResult } from '../../domain/servers.js' ;
21+ import { configForAppResult , configForServer , getServerTargets } from '../../domain/servers.js' ;
22+
23+ /**
24+ * Asked once, answered once.
25+ *
26+ * The single-server path prompts with the concrete release it is about to
27+ * restore. A fleet asks up front, before the roll starts, because prompting
28+ * inside the per-replica callback would ask N times — and the second prompt
29+ * would arrive with half the fleet already rolled back.
30+ */
31+ type Confirmer = ( message : string ) => Promise < boolean > ;
32+
33+ const alreadyConfirmed : Confirmer = async ( ) => true ;
2034
2135export async function cmdRollback (
2236 cwd : string ,
23- options : { steps ?: number ; app ?: string ; config ?: string } ,
37+ options : { steps ?: number ; app ?: string ; config ?: string ; on ?: string } ,
2438) : Promise < void > {
2539 if ( ! options . app ) {
2640 throw new Error (
@@ -35,77 +49,179 @@ export async function cmdRollback(
3549 return ;
3650 }
3751
38- await runRemoteCommandForConfig (
39- selectedConfig . value ,
40- async ( { config, executor } ) => {
41- const app = getActiveApp ( config , options . app ) ;
42- const appPath = `${ config . remotePath } /${ app . name } ` ;
43- const stepsBack = options . steps ?? 1 ;
44-
45- // Blue-green apps roll back by flipping Caddy back to the previous colour,
46- // which is still running — instant and zero-drop. No pm2 restart.
47- if ( app . appType === 'backend' && app . zeroDowntime ) {
48- await rollbackBlueGreen ( executor , config , app , appPath , stepsBack ) ;
49- return ;
50- }
51-
52- const releases = new ReleaseManager ( executor , appPath , app . keepReleases ) ;
53-
54- ui . info ( 'Fetching release history...' ) ;
55- const allReleases = await releases . listReleases ( ) ;
56-
57- if ( allReleases . length < 2 ) {
58- throw new Error ( 'No previous release to roll back to.' ) ;
59- }
60-
61- const targetIdx = allReleases . length - 1 - stepsBack ;
62- if ( targetIdx < 0 ) {
63- throw new Error (
64- `Cannot go back ${ stepsBack } step(s) — only ${ allReleases . length } release(s) recorded.` ,
65- ) ;
66- }
67-
68- const current = allReleases [ allReleases . length - 1 ] ;
69- const target = allReleases [ targetIdx ] ;
70-
71- ui . warn ( `Current release: ${ current . timestamp } ` ) ;
72- ui . warn ( `Target release: ${ target . timestamp } ` ) ;
73-
74- const ok = await confirm ( 'Proceed with rollback?' ) ;
75- if ( ! ok ) {
76- ui . info ( 'Rollback cancelled.' ) ;
77- return ;
78- }
79-
80- const targetPath = `${ appPath } /releases/${ target . timestamp } ` ;
81- await releases . switchSymlink ( targetPath ) ;
82- ui . success ( 'Symlink switched' ) ;
83-
84- const namespace = getDeploymentName ( config ) ;
85- if ( app . appType === 'backend' && namespace ) {
86- const nodeVersion = config . nodeVersion === 'lts' ? '24' : config . nodeVersion ;
87- const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"` ;
88- // Prefer reloading from the rolled-back release's ecosystem file (ADR-0001 — it
89- // restores the exact process set that was active for that release). Fall back to
90- // namespace reload if the target release predates per-release ecosystem files.
91- const ecosystem = getEcosystemPath ( config , app . name ) ;
92- await executor . execOrThrow (
93- `${ mise } ; mise exec node@${ nodeVersion } -- ` +
94- `(pm2 reload "${ ecosystem } " --update-env 2>/dev/null || pm2 reload ${ namespace } --update-env)` ,
95- ) ;
96- ui . success ( 'PM2 reloaded' ) ;
97- }
98-
99- if ( app . appType === 'backend' && app . healthCheck . enabled ) {
100- ui . info ( 'Running health check...' ) ;
101- const health = new HealthCheckService ( executor , config ) ;
102- await health . perform ( app ) ;
103- ui . success ( 'Health check passed' ) ;
104- }
105-
106- ui . success ( `Rolled back to ${ target . timestamp } ` ) ;
107- } ,
52+ const appConfig = selectedConfig . value ;
53+ const app = getActiveApp ( appConfig , options . app ) ;
54+ const stepsBack = options . steps ?? 1 ;
55+
56+ if ( app . fleet ) {
57+ await rollbackFleet ( appConfig , app , stepsBack , options . on ) ;
58+ return ;
59+ }
60+
61+ await runRemoteCommandForConfig ( appConfig , async ( { config, executor } ) => {
62+ await rollbackReplica ( executor , config , app , stepsBack , confirm ) ;
63+ } ) ;
64+ }
65+
66+ /**
67+ * Roll a fleet back one replica at a time, through the same drain/wait/undrain
68+ * machinery a deploy uses — a rollback that restarts PM2 has the same
69+ * traffic-dropping window a deploy does.
70+ *
71+ * Each replica re-reads its own state and rolls itself back one step rather than
72+ * being told what to do by a plan computed elsewhere. After a roll that failed
73+ * halfway the fleet is deliberately not uniform, and "everyone flip to green"
74+ * would put the already-correct replicas back on the bad release.
75+ */
76+ async function rollbackFleet (
77+ appConfig : ShipnodeConfig ,
78+ app : ShipnodeApp ,
79+ stepsBack : number ,
80+ on : string | undefined ,
81+ ) : Promise < void > {
82+ const allReplicas = getServerTargets ( appConfig ) . map ( ( target ) => target . name ) ;
83+ let replicas = allReplicas ;
84+ if ( on ) {
85+ if ( ! replicas . includes ( on ) ) {
86+ ui . error ( `App '${ app . name } ' does not run on '${ on } '. It runs on: ${ replicas . join ( ', ' ) } ` ) ;
87+ process . exit ( 1 ) ;
88+ return ;
89+ }
90+ replicas = [ on ] ;
91+ }
92+
93+ ui . warn (
94+ `Rolling ${ app . name } back ${ stepsBack } release(s) across ${ replicas . join ( ', ' ) } , ` +
95+ `${ app . fleet ! . batch } at a time.` ,
10896 ) ;
97+ if ( ! ( await confirm ( 'Proceed with rollback?' ) ) ) {
98+ ui . info ( 'Rollback cancelled.' ) ;
99+ return ;
100+ }
101+
102+ const result = await rollFleet ( {
103+ app,
104+ fleet : app . fleet ! ,
105+ replicas,
106+ primary : allReplicas [ 0 ] ,
107+ remotePath : appConfig . remotePath ,
108+ connect : async ( serverName ) => {
109+ const ssh = new SshConnection ( ) ;
110+ await ssh . connect ( configForServer ( appConfig , serverName ) . ssh ) ;
111+ return { executor : ssh , close : ( ) => ssh . disconnect ( ) } ;
112+ } ,
113+ applyToReplica : async ( { serverName, executor } ) => {
114+ const replicaConfig = { ...configForServer ( appConfig , serverName ) , apps : [ app ] } ;
115+ await rollbackReplica ( executor , replicaConfig , app , stepsBack , alreadyConfirmed ) ;
116+ } ,
117+ onEvent : ( event ) => reportRollbackEvent ( app , event ) ,
118+ } ) ;
119+
120+ if ( result . failed ) {
121+ ui . error (
122+ `${ app . name } : ${ result . failed . server } failed to roll back and is out of rotation. ` +
123+ `${ result . applied . length ? `${ result . applied . join ( ', ' ) } rolled back; ` : '' } ` +
124+ `${ result . skipped . length ? `${ result . skipped . join ( ', ' ) } untouched. ` : '' } ` +
125+ `The fleet is running mixed versions.` ,
126+ ) ;
127+ process . exit ( 1 ) ;
128+ return ;
129+ }
130+
131+ ui . success ( `${ app . name } rolled back on ${ result . applied . join ( ', ' ) } ` ) ;
132+ }
133+
134+ function reportRollbackEvent ( app : ShipnodeApp , event : FleetEvent ) : void {
135+ switch ( event . type ) {
136+ case 'drained' :
137+ ui . info ( `${ event . servers . join ( ', ' ) } draining — waiting ${ event . waitSeconds } s for the load balancer` ) ;
138+ break ;
139+ case 'applying' :
140+ ui . step ( `Rolling back ${ app . name } on ${ event . server } ` ) ;
141+ break ;
142+ case 'undrained' :
143+ ui . success ( `${ event . server } back in rotation` ) ;
144+ break ;
145+ case 'failed' :
146+ ui . error ( `${ event . server } : ${ event . message } ` ) ;
147+ break ;
148+ default :
149+ break ;
150+ }
151+ }
152+
153+ /** Roll one server back. `config` must already be scoped to that server. */
154+ async function rollbackReplica (
155+ executor : RemoteExecutor ,
156+ config : ShipnodeConfig ,
157+ app : ShipnodeApp ,
158+ stepsBack : number ,
159+ ask : Confirmer ,
160+ ) : Promise < void > {
161+ const appPath = `${ config . remotePath } /${ app . name } ` ;
162+
163+ // Blue-green apps roll back by flipping Caddy back to the previous colour,
164+ // which is still running — instant and zero-drop. No pm2 restart.
165+ if ( app . appType === 'backend' && app . zeroDowntime ) {
166+ await rollbackBlueGreen ( executor , config , app , appPath , stepsBack , ask ) ;
167+ return ;
168+ }
169+
170+ const releases = new ReleaseManager ( executor , appPath , app . keepReleases ) ;
171+
172+ ui . info ( 'Fetching release history...' ) ;
173+ const allReleases = await releases . listReleases ( ) ;
174+
175+ if ( allReleases . length < 2 ) {
176+ throw new Error ( 'No previous release to roll back to.' ) ;
177+ }
178+
179+ const targetIdx = allReleases . length - 1 - stepsBack ;
180+ if ( targetIdx < 0 ) {
181+ throw new Error (
182+ `Cannot go back ${ stepsBack } step(s) — only ${ allReleases . length } release(s) recorded.` ,
183+ ) ;
184+ }
185+
186+ const current = allReleases [ allReleases . length - 1 ] ;
187+ const target = allReleases [ targetIdx ] ;
188+
189+ ui . warn ( `Current release: ${ current . timestamp } ` ) ;
190+ ui . warn ( `Target release: ${ target . timestamp } ` ) ;
191+
192+ const ok = await ask ( 'Proceed with rollback?' ) ;
193+ if ( ! ok ) {
194+ ui . info ( 'Rollback cancelled.' ) ;
195+ return ;
196+ }
197+
198+ const targetPath = `${ appPath } /releases/${ target . timestamp } ` ;
199+ await releases . switchSymlink ( targetPath ) ;
200+ ui . success ( 'Symlink switched' ) ;
201+
202+ const namespace = getDeploymentName ( config ) ;
203+ if ( app . appType === 'backend' && namespace ) {
204+ const nodeVersion = config . nodeVersion === 'lts' ? '24' : config . nodeVersion ;
205+ const mise = `export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:$PATH"` ;
206+ // Prefer reloading from the rolled-back release's ecosystem file (ADR-0001 — it
207+ // restores the exact process set that was active for that release). Fall back to
208+ // namespace reload if the target release predates per-release ecosystem files.
209+ const ecosystem = getEcosystemPath ( config , app . name ) ;
210+ await executor . execOrThrow (
211+ `${ mise } ; mise exec node@${ nodeVersion } -- ` +
212+ `(pm2 reload "${ ecosystem } " --update-env 2>/dev/null || pm2 reload ${ namespace } --update-env)` ,
213+ ) ;
214+ ui . success ( 'PM2 reloaded' ) ;
215+ }
216+
217+ if ( app . appType === 'backend' && app . healthCheck . enabled ) {
218+ ui . info ( 'Running health check...' ) ;
219+ const health = new HealthCheckService ( executor , config ) ;
220+ await health . perform ( app ) ;
221+ ui . success ( 'Health check passed' ) ;
222+ }
223+
224+ ui . success ( `Rolled back to ${ target . timestamp } ` ) ;
109225}
110226
111227/**
@@ -122,6 +238,7 @@ async function rollbackBlueGreen(
122238 app : ShipnodeApp ,
123239 appPath : string ,
124240 stepsBack : number ,
241+ ask : Confirmer ,
125242) : Promise < void > {
126243 if ( app . blueGreenRetention === 'none' ) {
127244 throw new Error (
@@ -173,7 +290,7 @@ async function rollbackBlueGreen(
173290 ui . warn ( `Active colour: ${ state . activeColor } (port ${ portFor ( state . activeColor , state ) } )` ) ;
174291 ui . warn ( `Rollback target: ${ previous } (port ${ previousPort } )` ) ;
175292
176- const ok = await confirm ( 'Flip traffic back to the previous colour?' ) ;
293+ const ok = await ask ( 'Flip traffic back to the previous colour?' ) ;
177294 if ( ! ok ) {
178295 ui . info ( 'Rollback cancelled.' ) ;
179296 return ;
0 commit comments