diff --git a/api/app.py b/api/app.py index 07507de..87baa41 100644 --- a/api/app.py +++ b/api/app.py @@ -692,40 +692,64 @@ def apply_pending_config(): # existing network's subnet in-place, so we need `down` + `up` in that case. needs_network_recreate = pending.get('network_recreate', False) + host_env = os.path.join(project_dir, '.env') + host_compose = os.path.join(project_dir, 'docker-compose.yml') + if '*' in containers: + # All-services restart: `docker compose down` or `up -d` may stop/recreate the + # API container itself, killing this background thread mid-operation. + # Spawn an independent helper container using pic_api:latest that has docker CLI + # and survives cell-api being stopped/recreated. if needs_network_recreate: - # down removes containers AND the bridge network; up recreates everything - compose_down_args = ['down'] - compose_up_args = ['up', '-d'] + helper_script = ( + f'sleep 2' + f' && docker compose --project-directory {project_dir}' + f' -f {host_compose} --env-file {host_env} down' + f' && docker compose --project-directory {project_dir}' + f' -f {host_compose} --env-file {host_env} up -d' + ) else: - compose_down_args = None - compose_up_args = ['up', '-d'] + helper_script = ( + f'sleep 2' + f' && docker compose --project-directory {project_dir}' + f' -f {host_compose} --env-file {host_env} up -d' + ) + + def _do_apply(): + import subprocess as _subprocess + _subprocess.Popen( + ['docker', 'run', '--rm', + '-v', '/var/run/docker.sock:/var/run/docker.sock', + '-v', f'{project_dir}:{project_dir}', + '--entrypoint', 'sh', + 'pic_api:latest', + '-c', helper_script], + close_fds=True, + stdout=_subprocess.DEVNULL, + stderr=_subprocess.DEVNULL, + ) + logger.info( + 'spawned helper container for all-services restart' + + (' (network_recreate)' if needs_network_recreate else '') + ) else: - compose_down_args = None - compose_up_args = ['up', '-d', '--no-deps'] + containers - - base_cmd = ['docker', 'compose', - '--project-directory', project_dir, - '-f', '/app/docker-compose.yml', - '--env-file', '/app/.env.compose'] - - # Run in a background thread; 0.3 s delay lets Flask send this response first. - def _do_apply(): - import time as _time - import subprocess as _subprocess - _time.sleep(0.3) - if compose_down_args: - r = _subprocess.run(base_cmd + compose_down_args, - capture_output=True, text=True, timeout=60) - if r.returncode != 0: - logger.error(f"docker compose down failed: {r.stderr.strip()}") - return - result = _subprocess.run(base_cmd + compose_up_args, - capture_output=True, text=True, timeout=120) - if result.returncode != 0: - logger.error(f"docker compose up failed: {result.stderr.strip()}") - else: - logger.info(f'docker compose {" ".join(compose_up_args)} completed successfully') + # Specific containers only — API is not affected, run directly from here. + def _do_apply(): + import time as _time + import subprocess as _subprocess + _time.sleep(0.3) + result = _subprocess.run( + ['docker', 'compose', + '--project-directory', project_dir, + '-f', '/app/docker-compose.yml', + '--env-file', '/app/.env.compose', + 'up', '-d', '--no-deps'] + containers, + capture_output=True, text=True, timeout=120, + ) + if result.returncode != 0: + logger.error(f"docker compose up failed: {result.stderr.strip()}") + else: + logger.info(f'docker compose up completed for: {containers}') threading.Thread(target=_do_apply, daemon=False).start()