Worktree isolation

GoFastr can isolate local runtime resources for linked Git worktrees so a feature worktree can run beside the main checkout without port or database collisions.

Isolation is the isolation: section of gofastr.settings.yml:

12 lines
version: 1
isolation:
  enabled: true
  mode: worktree
  port:
    offset: 1000
    range: 1000
    scan: 20      # capped server-side at 64
  services:
    redis: 6379
  env:
    REDIS_URL: "redis://localhost:{port:redis}/0"

With mode: worktree, the main checkout keeps normal resources and linked worktrees get deterministic replacements. GOFASTR_ISOLATION=off disables isolation for a process.

Worktree isolation auto-activates with no opt-in. On any linked git worktree checkout, mode: worktree turns on by default and deterministically remaps the listen port — so PORT=8080 ./app run from a worktree serves on a different port, and anything pointed at 8080 (a test, a reverse proxy, you) never reaches the application. Two things make this visible: App.Start logs a slog.Warn ("isolation remapped the listen address") naming both ports and the kill switches whenever it remaps an explicitly-assigned address, and you can turn the remap off with GOFASTR_ISOLATION=off (all isolation) or GOFASTR_ISOLATION_REWRITE=0 (keep DB/worktree isolation, honor the explicit listen PORT and explicit env overrides). This is the checkout style agents use most, so a boot that "hangs" from a worktree is almost always this.

What is isolated

  • framework.App.Start(addr) remaps the requested listen port in linked worktrees.
  • gofastr dev resolves isolation before launching the app and passes isolated child env values.
  • Generated apps from gofastr init and app generator output use framework/isolation to resolve PORT and database DSNs.
  • SQLite DSNs move under .gofastr/isolation/{id}/ (the directory is created owner-only, 0700 or a protected owner-only ACL on Windows, so the isolated app's database state is not readable by local co-users of a shared checkout).
  • Postgres URL database names get a stable _{id} suffix while query params are preserved.
  • Env templates support {id}, {project_dir}, {port}, and {port:name} for named services.

isolation.env is read from the project directory, which for a cloned repo is untrusted input, so it is constrained three ways: entries may only carry template values (a static value is refused — it is never an isolated resource), process-shaping names (proxies, PATH, loader variables, HOME, GOFASTR_ISOLATION*) are refused outright, and an environment variable the operator already exported always wins over a file entry. Export anything static yourself; GOFASTR_ISOLATION_REWRITE now governs only the PORT and DATABASE_URL remaps.

Public API

Use framework/isolation when app code opens resources before calling App.Start:

10 lines
runtimeIsolation, err := isolation.Resolve(".")
if err != nil {
    log.Fatal(err)
}

driver, dsn, err := runtimeIsolation.Database("sqlite3", "file:app.db")
if err != nil {
    log.Fatal(err)
}
db, err := sql.Open(driver, dsn)

For the listen address, isolation.ListenAddr is the whole recipe: it reads $PORT (a bare 8088 from a PaaS or a localhost:8088 from gofastr dev), falls back to the address you pass when it is unset, and applies the port remap. Every example app binds this way, so go run . and gofastr dev agree on where the server is:

4 lines
addr, err := isolation.ListenAddr(".", ":8080")
if err != nil {
    log.Fatal(err)
}

A port is remapped once. App.Start resolves isolation again, but every active runtime in one process with the same project directory, isolation ID and port config shares the mappings already recorded, so passing it the result of ListenAddr keeps that address. The flip side: two servers in one process get the same port when they ask for the same base port, or when one asks for a port that is already the other's mapped port (with the default offset, :8080 maps near :9080, and a second server asking for that port keeps it). Give servers in one process base ports that are far apart. Runtime.Env hands each recorded mapping to a child process as a GOFASTR_ISOLATION_PORT_<base> marker, which is how the app that gofastr dev --addr localhost:8791 launches binds the port the dev banner prints.

Hand-written apps still get automatic port isolation through App.Start. Database isolation is automatic when the app either runs through gofastr dev and reads env, or opens the database through Runtime.Database.

Common mistakes

  • Opening the database before App.Start without resolving isolation. App.Start remaps the port, but a DSN you opened yourself is untouched, so two worktrees end up writing the same database. Open it through isolation.Resolve(".") → Runtime.Database(driver, dsn), or run under gofastr dev and read the env it injects.
  • Expecting an explicit PORT / DATABASE_URL to win inside a linked worktree. Explicit env values are rewritten by default so worktrees never collide. Set GOFASTR_ISOLATION_REWRITE=0 if you really want your override honored.
  • Looking for isolation effects in the main checkout. With mode: worktree, only linked worktrees get remapped resources. The main checkout keeps its normal port and database on purpose.
  • Fighting isolation instead of switching it off. For a one-off un-isolated run (e.g. comparing against the main checkout's DB), GOFASTR_ISOLATION=off disables it for that process.