Skip to content

Switch from bind mounts to named volumes for all platforms and runtimes #8

Description

@lefoulkrod

Plan: Switch from Bind Mounts to Named Volumes

Prerequisite / Ordering

This is part 2 of a two-part refactor. Part 1 is omnideck-dev/omnideck#110 (rename computronomnideck inside the container image). Part 1 must be merged and its image published to ghcr.io/omnideck-dev/omnideck:main before this lands, because:

  • The container mount targets change in part 1: /home/computron/home/omnideck and /var/lib/computron/var/lib/omnideck. All mount specs in this plan use the new paths.
  • The CLI's DefaultImage already points at ghcr.io/omnideck-dev/omnideck:main, so no image change is needed here — but if this CLI change ran against a pre-rename image, the volumes would mount at paths the old image never writes to, and no data would persist.

Goal

Replace host-path bind mounts (-v /Users/user/Omnideck:/home/computron) with Docker/Podman named volumes (-v omnideck-home:/home/omnideck) across all platforms (Linux, macOS, Windows) and all container runtimes (Docker Desktop, Colima, Podman, Docker Engine). This eliminates:

  • File ownership/permission issues on macOS (Colima, Docker Desktop)
  • SELinux :Z/:U flag complexity on Linux
  • The need for --user flags on Linux
  • sudo rm -rf during uninstall
  • Platform-conditional volume mount logic

Design Decisions

Volume Naming Convention

  • Home volume: {containerName}-home (e.g. omnideck-home)
  • State volume: {containerName}-state (e.g. omnideck-state)
  • Derived from ContainerName in config — guarantees uniqueness across multi-instance installs.
  • Derivation happens at use-time, not construction-time. Consumers call accessors — cfg.HomeVolumeName() / cfg.StateVolumeName() — which return the explicit HomeVolume/StateVolume field if set, else derive {ContainerName}-home / {ContainerName}-state. This avoids two bugs:
    1. DefaultConfig() baking in omnideck-home and then --name/TUI changing ContainerName afterward, leaving a second instance pointed at the first instance's volumes.
    2. Pre-existing instance YAMLs (which have shared_dir/state_dir but no volume keys) loading with empty volume names and producing a broken -v :/home/omnideck mount. The derive-on-empty accessor is the one backwards-compat shim we bake in — old configs transparently resolve to the correct derived volume names.

What Happens to SharedDir / StateDir Config Fields?

  • SharedDir and StateDir are removed from Config entirely. They are replaced by HomeVolume and StateVolume fields (string volume names), both optional overrides — empty means "derive from container name" (see accessors above).
  • The install TUI no longer asks the user for a shared directory path — the volume name is auto-derived from the container name.
  • The config set command's valid keys change: shared_dir/state_dirhome_volume/state_volume.
  • No data migration in the CLI. Old configs load fine (unknown YAML keys are ignored; missing volume keys derive from the container name), but the CLI does nothing about data sitting in old bind-mount directories. Users with existing bind-mount installs run a separate manual migration script (see below) to transfer data and clean up old directories.

Volume Lifecycle

  • Install: Volumes are created explicitly via docker volume create before docker run (ensures they exist and can be labeled/named predictably).
  • Uninstall: User is prompted to delete volumes (with optional backup via volume export).
  • Update: Volumes are preserved (not deleted) — only the container is recreated. Data persists across image updates.

Backup Mechanism (Runtime-Specific)

  • Podman: Use native podman volume export <name> (writes tar to stdout by default) and podman volume import <name> - (reads from stdin). Clean, purpose-built commands.
  • Docker (Docker Desktop, Docker Engine, Colima): No native volume export. Use a throwaway container to tar the volume to stdout:
    # Export
    docker run --rm -v omnideck-home:/data alpine tar -C /data -c . | gzip > backup.tar.gz
    # Import (restore)
    docker run --rm -i -v omnideck-home:/data alpine tar -C /data -x < backup.tar.gz
  • Both approaches work identically across all platforms (Linux, macOS, Windows) since they operate inside the container runtime.

Platform-Specific Logic That Gets REMOVED

  • :Z SELinux flags on Linux → gone (named volumes don't need them)
  • :U user-namespace remap on Linux Podman → gone
  • --user uid:gid on Linux → gone (named volumes are owned by the container runtime, not the host user)
  • safeRemoveAll() and its system-path protection → gone (we remove volumes, not host directories)
  • The macOS "Docker Desktop" warning about --network host → gone (we never used --network host anyway)

What Stays Platform-Conditional

  • OLLAMA_HOST — still needs host.docker.internal on macOS/Windows and host-gateway on Linux. This is about networking, not volumes, and is unchanged.
  • --add-host=host-gateway:host-gateway on Linux — unchanged (networking, not volumes).
  • Memory detection (/proc/meminfo vs sysctl) — unchanged.

Dev Workflow (Justfile) — Not Impacted

The Omnideck app's Justfile (just dev, just e2e, just manual-test) uses bind mounts directly via docker run — it does NOT go through the omnideck CLI. Bind mounts are intentional in dev because:

  • Developers want to inspect files at ~/.computron_9000/home on the host
  • E2e/manual-test use temp dirs with cleanup traps that chown + rm -rf (only works with bind mounts)
    The CLI change only affects production installs managed by the omnideck binary. The Justfile is a separate code path and is not modified.

Optional App-Side Cleanup (omnideck repo, not CLI repo)

(Paths below are the post-#110 names.) With named volumes, the general chown -R in container/entrypoint.sh becomes a no-op (named volumes inherit image ownership on first mount). The targeted chowns are still needed. This is an optional cleanup in the omnideck repo:

  • Remove: chown -R omnideck:omnideck /home/omnideck /var/lib/omnideck — redundant with named volumes. Caution: the Justfile dev/e2e flows still bind-mount host dirs into these paths, and they rely on this chown. Only remove it if dev flows are verified unaffected — otherwise keep it (it is harmless with volumes).
  • Keep: chown omnideck:broker /home/omnideck/downloads — sets cross-user ownership
  • Keep: chmod 3770 /home/omnideck/downloads — special setgid+sticky mode
  • Keep: chown -R broker:broker /var/lib/omnideck/vault — credential isolation
  • Update comment: the "Set up home dir" comment block references "bind-mounted host dirs" — update to mention named volumes
    This is not required for the CLI change to work (the chown is harmless), but is a cleanliness improvement.

Files to Change (in dependency order)

1. config/config.go — Config struct changes

  • Replace SharedDir string and StateDir string with HomeVolume string and StateVolume string (optional overrides, normally empty).
  • Add accessors HomeVolumeName() / StateVolumeName(): return the field if non-empty, else derive {ContainerName}-home / {ContainerName}-state. All consumers go through the accessors, never the raw fields.
  • DefaultConfig() leaves HomeVolume/StateVolume empty (derived).
  • Remove expandHome calls for SharedDir/StateDir in Load().
  • No MigrateVolumes() method — the derive-on-empty accessors already make old configs (no volume keys) resolve correctly.

2. config/config_test.go — Update tests

  • Replace SharedDir/StateDir assertions with HomeVolume/StateVolume.
  • Update TestDefaultConfig to check volume names.
  • No MigrateVolumes test — no backwards compatibility.

3. engine/engine.go — RunOptions struct + Engine interface

  • Replace SharedDir string and StateDir string with HomeVolume string and StateVolume string.
  • Add to the Engine interface:
    • CreateVolume(name string) error
    • VolumeExists(name string) (bool, error)
    • RemoveVolume(name string) error
    • ExportVolume(name string, w io.Writer) error

4. engine/docker.go — Docker engine implementation

  • Add CreateVolume(name string) error — runs docker volume create name.
  • Add VolumeExists(name string) (bool, error) — runs docker volume inspect name, checks exit code.
  • Add RemoveVolume(name string) error — runs docker volume rm name.
  • Add ExportVolume(name string, w io.Writer) error — runs a throwaway container (docker run --rm -v <name>:/data alpine tar -C /data -c .) piped to the writer.
  • Update buildRunArgs(): replace bind mount args with -v homeVolume:/home/omnideck -v stateVolume:/var/lib/omnideck (the post-#110 container paths). Remove all :Z, :U, --user, and platform-conditional volume logic.

5. engine/podman.go — Podman engine implementation

  • Add CreateVolume(name string) error — runs podman volume create name.
  • Add VolumeExists(name string) (bool, error) — runs podman volume inspect name.
  • Add RemoveVolume(name string) error — runs podman volume rm name.
  • Add ExportVolume(name string, w io.Writer) error — uses Podman's native podman volume export <name> (tar to stdout) piped to the writer (more efficient than throwaway container).
  • Update buildPodmanRunArgs(): same simplification as docker — remove :Z,U, use named volumes mounted at /home/omnideck and /var/lib/omnideck.

6. engine/engine_test.go — Update engine tests

  • Update TestBuildRunArgsLinux: replace bind mount assertions with named volume assertions. Remove :Z checks. Remove --user check.
  • Update TestBuildRunArgsMacOS: replace bind mount assertions with named volume assertions.
  • Update TestBuildRunArgsLinuxSecondInstance: update volume name assertions.
  • Update TestBuildPodmanRunArgsHasReplace: remove :Z,U checks, add named volume checks.
  • Update TestBuildPodmanRunArgsMacOS: remove :Z,U checks, add named volume checks.
  • Update TestBuildRunArgsMemorySet/TestBuildRunArgsMemoryEmpty: update RunOptions fields.
  • Add tests for CreateVolume/VolumeExists/RemoveVolume (mock-based or interface-level).

7. tui/install.go — Install wizard

  • Remove inputSharedDir from the input constants and all related TUI logic.
  • Remove the "Shared directory" config input field from the form.
  • Update buildConfig() to stop setting SharedDir/StateDir (volume names derive from the container name via the accessors).
  • Update startInstallStep():
    • Step 0: "Create home volume" → eng.CreateVolume(cfg.HomeVolumeName()) instead of os.MkdirAll(cfg.SharedDir).
    • Step 1: "Create state volume" → eng.CreateVolume(cfg.StateVolumeName()) instead of os.MkdirAll(cfg.StateDir).
    • Step 4: RunContainer opts use HomeVolume/StateVolume instead of SharedDir/StateDir.
  • Update installStepLabels: "Create shared directory" → "Create home volume", "Create state directory" → "Create state volume".
  • Update viewConfig(): remove the shared directory field from the form display.
  • Update viewConfirm(): show volume names instead of directory paths.
  • Update viewDone(): show volume names instead of "Shared dir".
  • Remove the macOS --network host warning in buildConfirmWarnings().
  • Update validateCurrentInput(): remove the inputSharedDir case.

8. tui/install_test.go — Update install tests

  • Remove inputSharedDir references.
  • Update TestBuildConfig to check derived volume names instead of SharedDir/StateDir.
  • Update TestNewInstallModelDefaults to check volume defaults.
  • Remove shared dir validation tests if any.

8b. tui/install_tn.go — Tokyo-Night install renderer (added since this plan was written)

  • References inputSharedDir at lines 144 and 186 (sharedDir value + "Shared dir" summary row).
  • Remove the shared-dir input rendering; show derived volume names in the summary instead.

8c. cmd/install.go — Headless install path (--plain, added since this plan was written)

  • Remove the --shared-dir flag (installSharedFlag) — volume names derive from --name.
  • runInstallPlain() steps: replace the two os.MkdirAll steps ("Create shared directory"/"Create state directory") with eng.CreateVolume(cfg.HomeVolumeName()) / eng.CreateVolume(cfg.StateVolumeName()).
  • RunOptions: HomeVolume: cfg.HomeVolumeName(), StateVolume: cfg.StateVolumeName().
  • suggestInstallDefaults()-equivalent logic in this file (sets d.SharedDir/d.StateDir for instance N+1 naming) — drop the dir fields; container-name suffixing alone now differentiates instances.

8d. tui/dashboard.go — Dashboard (added since this plan was written)

  • Config editor (configFields, ~line 339): remove the shared_dir path field; optionally show home_volume/state_volume as fields backed by the raw override fields (empty = derived).
  • suggestInstallDefaults() (~line 435): stop setting SharedDir/StateDir.

8e. tui/menu.go + tui/menu_test.go — Menu/status pane (added since this plan was written)

  • Replace dirExists(cfg.SharedDir) / dirExists(cfg.StateDir) (~line 351) with eng.VolumeExists(cfg.HomeVolumeName()) / eng.VolumeExists(cfg.StateVolumeName()).
  • Labels "SHARED DIR"/"STATE DIR" (~line 394) → "HOME VOLUME"/"STATE VOLUME", showing volume names.
  • Update menu_test.go accordingly.

9. tui/update.go — Update wizard

  • Update startUpdateStep() step 3 (Run container): use cfg.HomeVolumeName()/cfg.StateVolumeName() in RunOptions.

10. cmd/uninstall.go — Uninstall command

  • Replace directory deletion logic with volume deletion:
    • Replace backupDirs() with backupVolumes() that uses eng.ExportVolume().
    • Replace os.RemoveAll(dir) with eng.RemoveVolume(name).
    • Replace safeRemoveAll() path safety checks (no longer needed for volumes, but keep a basic name validation).
    • Remove the os.IsPermission / sudo rm -rf error handling (not possible with volumes).
    • Remove the addDirToTar() / backupDirs() / safeRemoveAll() functions entirely.
    • Update prompts: "Delete data directories" → "Delete data volumes".

11. cmd/status.go — Status command

  • Replace os.Stat(cfg.SharedDir) / os.Stat(cfg.StateDir) with eng.VolumeExists(cfg.HomeVolumeName()) / eng.VolumeExists(cfg.StateVolumeName()).
  • Update display labels: "Shared dir" → "Home volume", "State dir" → "State volume".

12. cmd/config.go — Config command

  • Update validConfigKeys: replace shared_dir, state_dir with home_volume, state_volume.
  • Update runConfigShow(): display home_volume and state_volume instead of shared_dir and state_dir.
  • Update runConfigSet(): handle home_volume and state_volume keys.

13. cmd/config_test.go — Config command tests

  • Update validConfigKeys test to use new key names.

14. tui/doctor.go — Doctor checks

  • Update check 6 (Shared dir) and check 7 (State dir) to check volume existence via eng.VolumeExists() instead of os.Stat().
  • Update labels: "Shared dir" → "Home volume", "State dir" → "State volume".
  • Update dirCheck()volumeCheck() or adapt to use the engine.

15. tui/doctor_test.go — Doctor test

  • Update test data if any references to "Shared dir"/"State dir".

16. checks/ollama.go — No changes needed

  • Ollama host detection is about networking, not volumes. Unchanged.

17. checks/memory.go — No changes needed

  • Memory detection is about host RAM, not volumes. Unchanged.

18. CLAUDE.md — Update documentation

  • Update the platform rules table: remove the SELinux/volume row.
  • Update the config struct documentation.
  • Update the naming conventions (add volume naming).

19. SPEC.md / PHASES.md — no longer exist

  • Both files were deleted from the repo (main @ 9a11ce4). Nothing to do.

20. scripts/migrate-to-named-volumes.sh — New standalone migration script

  • Reads old config YAML to extract container name, engine (docker/podman), and old host paths (shared_dir/state_dir)
  • Uses that engine's binary for every step (don't assume docker)
  • Stops and removes the old container
  • Creates named volumes ({name}-home, {name}-state)
  • Copies data from old bind-mount dirs into volumes via throwaway containers; mount the source dir read-only, and on SELinux-enforcing hosts run the copy container with --security-opt label=disable (avoids relabeling the source dir with :Z)
  • Prints cleanup instructions for old host directories
  • Not invoked by the CLI — user runs it manually

Migration for Existing Installations (Manual Script)

The CLI does not handle backwards compatibility. Users with existing bind-mount installs run a separate shell script (scripts/migrate-to-named-volumes.sh) that:

  1. Reads the old config to get the container name and old host paths
  2. Stops the existing container
  3. Creates the named volumes ({name}-home, {name}-state)
  4. Copies data from old bind-mount directories into the new volumes using throwaway containers ($ENGINE is docker or podman from the old config; add --security-opt label=disable on SELinux-enforcing hosts):
    $ENGINE run --rm -v /old/path:/src:ro -v {name}-home:/dst alpine sh -c "cp -a /src/. /dst/"
    $ENGINE run --rm -v /old/state:/src:ro -v {name}-state:/dst alpine sh -c "cp -a /src/. /dst/"
  5. Removes the old container
  6. Prints instructions to delete old host directories (rm -rf ~/Omnideck)
  7. Tells the user to run omnideck install (or omnideck update) with the new CLI to recreate the container with named volumes

The script is a standalone bash file in the repo at scripts/migrate-to-named-volumes.sh. It is not invoked by the CLI — the user runs it manually.


Test Plan

Unit Tests

  • config_test.go: volume-name accessors (explicit override, derive-on-empty, old YAML with only shared_dir/state_dir still derives correctly), Save/Load with new fields
  • engine_test.go: buildRunArgs with named volumes mounted at /home/omnideck + /var/lib/omnideck (all platforms), no :Z/:U/--user flags
  • install_test.go: buildConfig produces volume names, no shared dir input
  • menu_test.go: volume labels/checks
  • config_test.go (cmd): valid keys include home_volume/state_volume
  • doctor_test.go: updated labels
  • tests/smoke: re-run; update any assertions touching shared/state dirs

Integration Tests (manual or CI)

  • omnideck install creates volumes and starts container with named volumes
  • omnideck status shows volume existence
  • omnideck uninstall removes volumes (with and without backup)
  • omnideck update preserves volumes across container recreation
  • Manual migration script transfers data from old bind mounts to named volumes

Build Verification

  • go build ./... compiles
  • go vet ./... passes
  • go test ./... all pass

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions