@@ -110,6 +110,83 @@ function parseTopLevelVolumes(composeYaml: string): string[] {
110110 return names ;
111111}
112112
113+ // Parses every `container_name:` entry from a docker-compose.yml. These are the
114+ // fixed names Docker assigns the containers, and a pre-existing container with the
115+ // same name (e.g. from an older `docker run --name sourcebot ...`) makes
116+ // `docker compose up` fail with "The container name ... is already in use".
117+ function parseComposeContainerNames ( composeYaml : string ) : string [ ] {
118+ const names : string [ ] = [ ] ;
119+ for ( const rawLine of composeYaml . split ( '\n' ) ) {
120+ const line = rawLine . replace ( / \r $ / , '' ) ;
121+ const m = line . match ( / ^ \s + c o n t a i n e r _ n a m e : \s * ( .+ ?) \s * $ / ) ;
122+ if ( m ) {
123+ names . push ( m [ 1 ] . replace ( / ^ [ " ' ] | [ " ' ] $ / g, '' ) . trim ( ) ) ;
124+ }
125+ }
126+ return names ;
127+ }
128+
129+ // A pre-existing container that would collide with a declared `container_name`.
130+ type ConflictingContainer = { name : string ; id : string ; project : string } ;
131+
132+ // Finds existing containers (running or stopped) whose name matches one of the given
133+ // names. Returns the container id and its compose project label (empty if it isn't
134+ // compose-managed) so callers can ignore containers belonging to the current project.
135+ async function findConflictingContainers ( names : string [ ] ) : Promise < ConflictingContainer [ ] > {
136+ if ( names . length === 0 ) {
137+ return [ ] ;
138+ }
139+ return new Promise < ConflictingContainer [ ] > ( ( resolve ) => {
140+ const child = spawn (
141+ 'docker' ,
142+ [ 'ps' , '-a' , '--no-trunc' , '--format' , '{{.Names}}\t{{.ID}}\t{{.Label "com.docker.compose.project"}}' ] ,
143+ { stdio : [ 'ignore' , 'pipe' , 'ignore' ] } ,
144+ ) ;
145+ let out = '' ;
146+ child . stdout ?. on ( 'data' , ( chunk : Buffer ) => {
147+ out += chunk . toString ( ) ;
148+ } ) ;
149+ child . on ( 'exit' , ( code ) => {
150+ if ( code !== 0 ) {
151+ resolve ( [ ] ) ;
152+ return ;
153+ }
154+ const wanted = new Set ( names ) ;
155+ const conflicts : ConflictingContainer [ ] = [ ] ;
156+ for ( const line of out . split ( '\n' ) ) {
157+ const [ name , id , project ] = line . split ( '\t' ) ;
158+ if ( name && id && wanted . has ( name ) ) {
159+ conflicts . push ( { name, id, project : ( project ?? '' ) . trim ( ) } ) ;
160+ }
161+ }
162+ resolve ( conflicts ) ;
163+ } ) ;
164+ child . on ( 'error' , ( ) => resolve ( [ ] ) ) ;
165+ } ) ;
166+ }
167+
168+ // Force-removes the given containers (by id or name). Returns true only if all
169+ // removed cleanly.
170+ async function removeDockerContainers ( ids : string [ ] ) : Promise < boolean > {
171+ if ( ids . length === 0 ) {
172+ return true ;
173+ }
174+ return new Promise < boolean > ( ( resolve ) => {
175+ const child = spawn ( 'docker' , [ 'rm' , '-f' , ...ids ] , { stdio : [ 'ignore' , 'ignore' , 'pipe' ] } ) ;
176+ let err = '' ;
177+ child . stderr ?. on ( 'data' , ( chunk : Buffer ) => {
178+ err += chunk . toString ( ) ;
179+ } ) ;
180+ child . on ( 'exit' , ( code ) => {
181+ if ( code !== 0 && err . trim ( ) ) {
182+ console . error ( chalk . red ( '✗ ' ) + err . trim ( ) ) ;
183+ }
184+ resolve ( code === 0 ) ;
185+ } ) ;
186+ child . on ( 'error' , ( ) => resolve ( false ) ) ;
187+ } ) ;
188+ }
189+
113190// A published port from a compose `ports:` entry, with the host interface Docker
114191// would bind to. Container-only, range, and env-interpolated specs are skipped.
115192type PublishedPort = { host : string ; port : number } ;
@@ -746,6 +823,39 @@ async function main() {
746823 }
747824 }
748825
826+ // A container created outside this compose project but sharing a declared
827+ // `container_name` (e.g. a leftover `docker run --name sourcebot ...` from an older
828+ // install) makes `docker compose up` fail with "The container name ... is already in
829+ // use". The compose cleanup above only removes our own project's containers, so check
830+ // for foreign name collisions here and offer to remove them.
831+ if ( downloadedCompose && ! leftDeploymentRunning ) {
832+ const project = dockerComposeProjectName ( ) ;
833+ const containerNames = parseComposeContainerNames ( readFileSync ( 'docker-compose.yml' , 'utf-8' ) ) ;
834+ const conflicts = ( await findConflictingContainers ( containerNames ) )
835+ . filter ( ( c ) => c . project !== project ) ;
836+
837+ if ( conflicts . length > 0 ) {
838+ console . log ( ) ;
839+ console . log ( chalk . yellow ( '⚠ ' ) + 'The following existing container names conflict with Sourcebot and will prevent it from starting:' ) ;
840+ for ( const c of conflicts ) {
841+ console . log ( ' ' + chalk . dim ( '- ' ) + c . name ) ;
842+ }
843+ const remove = await confirm ( {
844+ message : `Remove ${ conflicts . length === 1 ? 'this container' : 'these containers' } so Sourcebot can start?` ,
845+ default : true ,
846+ } ) ;
847+ if ( remove ) {
848+ const cs = ora ( 'Removing containers...' ) . start ( ) ;
849+ const ok = await removeDockerContainers ( conflicts . map ( ( c ) => c . id ) ) ;
850+ if ( ok ) {
851+ cs . succeed ( `Removed ${ conflicts . length } container${ conflicts . length === 1 ? '' : 's' } ` ) ;
852+ } else {
853+ cs . fail ( 'Failed to remove one or more containers' ) ;
854+ }
855+ }
856+ }
857+ }
858+
749859 // Volume wipe is only safe (and only succeeds) once nothing is using the volumes.
750860 if ( downloadedCompose && ! leftDeploymentRunning ) {
751861 const declaredVolumes = parseTopLevelVolumes ( readFileSync ( 'docker-compose.yml' , 'utf-8' ) ) ;
0 commit comments