Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions dev/freesurfer-volume-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# FreeSurfer volume versioning

## Problem

The FreeSurfer install (`/usr/local/freesurfer/`) is shared into the simnibs
container through a Docker named volume. Docker only seeds a volume from the
image **once**, when the volume is first created empty. The volume had a fixed
name (`ti-toolbox_freesurfer_data`), so once a user created it, it was never
refreshed — even after pulling a newer freesurfer image. Users who installed
before the image gained the MATLAB runtime (MCR) kept an MCR-less volume, which
broke `segmentThalamicNuclei.sh` / `segmentHA_T1.sh` (GitHub issue #123).

## Fix

1. **Version the volume name by the freesurfer image tag**
(`ti-toolbox_freesurfer_data_v7.4.1`). Bumping the image bumps the name, so a
new empty volume is created and re-seeded from the new image.
2. **Make the volume compose-managed (not `external`)** so `docker compose up`
creates and seeds it automatically — no launcher pre-step, and a raw
`docker compose up` works too.
3. **Launchers derive the name** from the compose image tag, pass it as
`FREESURFER_VOLUME`, and prune stale older-version volumes (best-effort).

`docker compose down` keeps the volume; only `down -v` removes it.

## Files changed

| File | Change |
|------|--------|
| `docker-compose.yml` | `freesurfer_data` volume: drop `external`, `name: ${FREESURFER_VOLUME:-ti-toolbox_freesurfer_data_v7.4.1}` |
| `package/docker/docker-compose.yml` | same |
| `dev/loader/docker-compose.dev.yml` | same |
| `loader.py` | `get_freesurfer_volume_name()` + `prune_old_freesurfer_volumes()`; set `FREESURFER_VOLUME`; removed manual volume create |
| `dev/loader/loader_dev.py` | same |
| `package/src/backend/docker-manager.js` | `computeFreesurferVolume()` + `pruneOldVolumes()`; inject `FREESURFER_VOLUME`; removed `ensureVolume()` |

## When bumping the FreeSurfer image

Update the freesurfer `image:` tag **and** the matching `${FREESURFER_VOLUME:-...}`
default in all three compose files. The launchers read the tag automatically; the
default only matters for a bare `docker compose up`.

## Existing users (one-time)

Their old `ti-toolbox_freesurfer_data` volume is replaced automatically on next
launch (new versioned volume is seeded fresh; the launcher prunes the old one).
A manual reset also works:

```bash
docker rm -f simnibs_container freesurfer_container
docker volume rm ti-toolbox_freesurfer_data
```
11 changes: 6 additions & 5 deletions dev/loader/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
platform: linux/amd64
container_name: freesurfer_container
volumes:
- ti-toolbox_freesurfer_data:/usr/local/freesurfer/
- freesurfer_data:/usr/local/freesurfer/
networks:
- ti_network
restart: unless-stopped
Expand All @@ -14,7 +14,7 @@ services:
image: idossha/simnibs:v2.3.2
container_name: simnibs_container
volumes:
- ti-toolbox_freesurfer_data:/usr/local/freesurfer/
- freesurfer_data:/usr/local/freesurfer/
- /tmp/.X11-unix:/tmp/.X11-unix
- ${LOCAL_PROJECT_DIR}:/mnt/${PROJECT_DIR_NAME}
- ${TIT_USER_CONFIG}:/root/.config/ti-toolbox # User-level config (telemetry, prefs)
Expand Down Expand Up @@ -55,6 +55,7 @@ networks:
driver: bridge

volumes:
ti-toolbox_freesurfer_data:
external: true
name: ti-toolbox_freesurfer_data
freesurfer_data:
# Versioned name (must match the freesurfer image tag) so a FreeSurfer bump
# re-seeds a fresh volume. Launcher overrides FREESURFER_VOLUME.
name: ${FREESURFER_VOLUME:-ti-toolbox_freesurfer_data_v7.4.1}
55 changes: 55 additions & 0 deletions dev/loader/loader_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_PATHS_FILE = SCRIPT_DIR / ".default_paths.dev"
DOCKER_COMPOSE_FILE = SCRIPT_DIR / "docker-compose.dev.yml"
FREESURFER_VOLUME_PREFIX = "ti-toolbox_freesurfer_data"
TOOLBOX_ROOT = (SCRIPT_DIR / ".." / "..").resolve()
STATUS_RELATIVE_PATH = Path("code/ti-toolbox/config/project_status.json")
SYSTEM_INFO_RELATIVE_DIR = Path("derivatives/ti-toolbox/.ti-toolbox-info")
Expand Down Expand Up @@ -166,6 +167,43 @@ def initialize_project_structure(project_dir: Path) -> None:
initializer.setup_example_data(str(TOOLBOX_ROOT), project_dir)


def get_freesurfer_volume_name() -> Optional[str]:
"""Versioned FreeSurfer volume name from the compose image tag.

Returns ``None`` if the image tag cannot be parsed. Callers must then leave
``FREESURFER_VOLUME`` unset (so compose's versioned default seeds the correct
volume) and skip pruning, rather than falling back to the bare prefix.
"""
for line in DOCKER_COMPOSE_FILE.read_text().splitlines():
match = re.match(r"^\s*image:\s*\S*ti-toolbox_freesurfer:(\S+)\s*$", line)
if match:
return f"{FREESURFER_VOLUME_PREFIX}_{match.group(1)}"
return None


def prune_old_freesurfer_volumes(current_name: Optional[str]) -> None:
"""Remove stale older-version FreeSurfer volumes (best-effort).

A ``None`` ``current_name`` means the active version is unknown (tag parse
failed); pruning is skipped so a good versioned volume is never destroyed.
"""
if not current_name:
return
try:
names = capture(["docker", "volume", "ls", "--format", "{{.Name}}"]).split()
except Exception:
return
for name in names:
is_versioned = name.startswith(f"{FREESURFER_VOLUME_PREFIX}_")
is_legacy = name == FREESURFER_VOLUME_PREFIX
if (is_versioned or is_legacy) and name != current_name:
subprocess.run(
["docker", "volume", "rm", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)


def get_compose_images() -> list[str]:
images: list[str] = []
for line in DOCKER_COMPOSE_FILE.read_text().splitlines():
Expand Down Expand Up @@ -309,6 +347,23 @@ def main() -> None:
env["TIT_HOST_OS"] = platform.system().lower() # darwin, linux, windows
env["TIT_HOST_OS_VERSION"] = platform.release()
env["TIT_HOST_ARCH"] = platform.machine() # x86_64, arm64

freesurfer_volume = get_freesurfer_volume_name()
if freesurfer_volume:
env["FREESURFER_VOLUME"] = freesurfer_volume

# Bring any containers from a previous (older-image) run down first so they
# release the old FreeSurfer volume; otherwise the prune below fails with
# "volume is in use" and the stale volume lingers.
subprocess.run(
["docker", "compose", "-f", str(DOCKER_COMPOSE_FILE), "down"],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
prune_old_freesurfer_volumes(freesurfer_volume)

ensure_images_pulled(env)
run_docker_compose(env, dev_codebase_dir)

Expand Down
11 changes: 6 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
platform: linux/amd64
container_name: freesurfer_container
volumes:
- ti-toolbox_freesurfer_data:/usr/local/freesurfer/
- freesurfer_data:/usr/local/freesurfer/
networks:
- ti_network
restart: unless-stopped
Expand All @@ -14,7 +14,7 @@ services:
image: idossha/simnibs:v2.3.2
container_name: simnibs_container
volumes:
- ti-toolbox_freesurfer_data:/usr/local/freesurfer/
- freesurfer_data:/usr/local/freesurfer/
- /tmp/.X11-unix:/tmp/.X11-unix:rw # X11 socket mount for GUI
- ${LOCAL_PROJECT_DIR}:/mnt/${PROJECT_DIR_NAME}
- ${TIT_USER_CONFIG}:/root/.config/ti-toolbox # User-level config (telemetry, prefs)
Expand Down Expand Up @@ -51,6 +51,7 @@ networks:
driver: bridge

volumes:
ti-toolbox_freesurfer_data:
external: true
name: ti-toolbox_freesurfer_data
freesurfer_data:
# Versioned name (must match the freesurfer image tag) so a FreeSurfer bump
# re-seeds a fresh volume. Launchers override FREESURFER_VOLUME.
name: ${FREESURFER_VOLUME:-ti-toolbox_freesurfer_data_v7.4.1}
61 changes: 52 additions & 9 deletions loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_PATHS_FILE = SCRIPT_DIR / ".default_paths.user"
DOCKER_COMPOSE_FILE = SCRIPT_DIR / "docker-compose.yml"
FREESURFER_VOLUME_PREFIX = "ti-toolbox_freesurfer_data"
X11_MARKER_NAME = ".ti_toolbox_x11_initialized"
X11_MARKER_DIR = Path("code/ti-toolbox/config")

Expand Down Expand Up @@ -218,14 +219,41 @@ def maybe_init_macos_x11(
print("XQuartz: ensure 'Allow connections from network clients' is enabled.")


def ensure_docker_volume(volume_name: str) -> None:
result = subprocess.run(
["docker", "volume", "inspect", volume_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
run(["docker", "volume", "create", volume_name], check=False)
def get_freesurfer_volume_name() -> Optional[str]:
"""Versioned FreeSurfer volume name from the compose image tag.

Returns ``None`` if the image tag cannot be parsed. Callers must then leave
``FREESURFER_VOLUME`` unset (so compose's versioned default seeds the correct
volume) and skip pruning, rather than falling back to the bare prefix.
"""
for line in DOCKER_COMPOSE_FILE.read_text().splitlines():
match = re.match(r"^\s*image:\s*\S*ti-toolbox_freesurfer:(\S+)\s*$", line)
if match:
return f"{FREESURFER_VOLUME_PREFIX}_{match.group(1)}"
return None


def prune_old_freesurfer_volumes(current_name: Optional[str]) -> None:
"""Remove stale older-version FreeSurfer volumes (best-effort).

A ``None`` ``current_name`` means the active version is unknown (tag parse
failed); pruning is skipped so a good versioned volume is never destroyed.
"""
if not current_name:
return
try:
names = capture(["docker", "volume", "ls", "--format", "{{.Name}}"]).split()
except Exception:
return
for name in names:
is_versioned = name.startswith(f"{FREESURFER_VOLUME_PREFIX}_")
is_legacy = name == FREESURFER_VOLUME_PREFIX
if (is_versioned or is_legacy) and name != current_name:
subprocess.run(
["docker", "volume", "rm", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)


def get_compose_images() -> list[str]:
Expand Down Expand Up @@ -300,9 +328,24 @@ def run_project_init_in_container(container_name: str, project_dir_name: str) ->


def run_docker_compose(project_dir: Path, project_dir_name: str) -> None:
ensure_docker_volume("ti-toolbox_freesurfer_data")
freesurfer_volume = get_freesurfer_volume_name()

env = os.environ.copy()
if freesurfer_volume:
env["FREESURFER_VOLUME"] = freesurfer_volume

# Bring any containers from a previous (older-image) run down first so they
# release the old FreeSurfer volume; otherwise the prune below fails with
# "volume is in use" and the stale volume lingers.
subprocess.run(
["docker", "compose", "-f", str(DOCKER_COMPOSE_FILE), "down"],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
prune_old_freesurfer_volumes(freesurfer_volume)

env["LOCAL_PROJECT_DIR"] = str(project_dir)
env["PROJECT_DIR_NAME"] = project_dir_name
env["TZ"] = get_host_timezone()
Expand Down
11 changes: 6 additions & 5 deletions package/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ services:
platform: linux/amd64
container_name: freesurfer_container
volumes:
- ti-toolbox_freesurfer_data:/usr/local/freesurfer/
- freesurfer_data:/usr/local/freesurfer/
networks:
- ti_network
restart: unless-stopped
Expand All @@ -16,7 +16,7 @@ services:
image: idossha/simnibs:v2.3.2
container_name: simnibs_container
volumes:
- ti-toolbox_freesurfer_data:/usr/local/freesurfer/
- freesurfer_data:/usr/local/freesurfer/
- /tmp/.X11-unix:/tmp/.X11-unix:rw # X11 socket mount for Linux/macOS
- ${LOCAL_PROJECT_DIR}:/mnt/${PROJECT_DIR_NAME}
- ${TIT_USER_CONFIG}:/root/.config/ti-toolbox # User-level config (telemetry, prefs)
Expand Down Expand Up @@ -54,7 +54,8 @@ networks:
driver: bridge

volumes:
ti-toolbox_freesurfer_data:
external: true
name: ti-toolbox_freesurfer_data
freesurfer_data:
# Versioned name (must match the freesurfer image tag) so a FreeSurfer bump
# re-seeds a fresh volume. Launchers override FREESURFER_VOLUME.
name: ${FREESURFER_VOLUME:-ti-toolbox_freesurfer_data_v7.4.1}

Loading