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
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Metrics/ClassLength:
Exclude:
- lib/wip/cli.rb
- lib/wip/compose_file.rb
- lib/wip/config.rb
Metrics/BlockLength:
Exclude:
- spec/**/*
Expand Down
69 changes: 59 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,12 @@ The two are aliases for the same feature, not separate ones: pick whichever name

`dependencies:` holds every container uniformly — the primary one `container:` points at and any
sidecar services (a database, Redis, ...) alongside it. Each entry accepts `image` (required),
`command`, `env`, `ports`, `volumes`, and `workdir`; there's no separate, differently-shaped block
for "the one you exec into." `container:` has no default; once `dependencies:` has any entries,
wip needs to be told explicitly which one is primary rather than guessing a name.
`command`, `env`, `ports`, `volumes`, `workdir`, and `restart`; there's no separate, differently-shaped
block for "the one you exec into." `container:` has no default; once `dependencies:` has any entries,
wip needs to be told explicitly which one is primary rather than guessing a name. `restart` is stored
but inert on its own — see
[Restarting exited dependencies](#restarting-exited-dependencies-wip-up---watch) for what actually
acts on it (`wip up --watch`).

What sets the primary entry apart is operational, not structural: `wip up` brings up every other
entry by name first (creating `network:` beforehand if it doesn't exist and set), then boots or
Expand All @@ -172,6 +175,46 @@ in place). Only the primary container is a target for `exec`/`run`/`build`/`inte
are only ever started and stopped, matching Compose's own service-vs-you-exec-into-one-of-them
split.

#### Restarting exited dependencies (`wip up --watch`)

Real Compose auto-restarts a container tagged `restart: always`/`unless-stopped`/`on-failure` when
it exits. `wslc` has no such policy, and no push-based "container exited" notification for `wip` to
hook into, so the closest approximation is polling: `wip up --watch` brings everything up the same
way `wip up -d` does, then keeps checking (`--interval SECONDS`, default 5) whether any
dependency — the primary container included — has exited, restarting the ones whose `restart:`
allows it.

```console
$ wip up --watch
wip: watching app, mysql for exited restart: containers every 5s (running detached; Ctrl-C to stop)
```

- `restart:` accepts the same values Compose does — `no` (the default), `always`, `unless-stopped`,
`on-failure`, optionally with a `:MAX_RETRIES` suffix. `wip up --watch` treats the three
restarting values identically: it restarts on any exited container regardless of exit code,
unlike real `on-failure`, which skips a clean (zero) exit — reading an exit code needs a heavier
call this polling loop doesn't make.
- This is a foreground loop, not a background daemon or service — the project intentionally has
neither (see [Roadmap](#roadmap)). Keep the terminal it's running in open, the same as
`wip sync --watch`; Ctrl-C (or closing the terminal) stops the supervision.
- `--watch` implies `-d`: it can't attach a TTY to the primary container and poll in a loop on the
same thread, so the primary container always runs detached under `--watch`, whether or not you
also passed `-d`.
- Not available under `mode: compose` — wip never parses a service list in that mode, so there's
nothing for it to poll; use whatever restart support your external compose-for-`wslc` tool offers.
- It's status-based, not event-based: each tick checks whether a dependency is currently exited, not
whether it *just* exited. It can't tell "crashed on its own" apart from "you ran `wip stop`/
`wip down` in another terminal" — Ctrl-C the `--watch` loop first, or it may race and restart
what you just stopped.
- Re-running `wip up -d --watch` against an already-running stack is safe: an already-running
container's `start` is a no-op, the same as it is for plain `wip up`.
- The exit-detection reads `wslc list --all --format json`'s `State` field as a raw integer, per
`WslcContainerState` ([wsl.dev](https://wsl.dev/api-reference/c/enumerations/wslccontainerstate/)):
`0` invalid, `1` created, `2` running, `3` exited, `4` deleted. Unlike Docker, there's no separate
`dead` state — a `deleted` container is gone and needs `wip up` (not `--watch`) to recreate it.
If `--watch` never restarts anything you believe really exited, run `wip up --watch --debug` and
check the logged `list` entry against this enum.

### Compose mode

If your project already has a real `compose.yml`, don't duplicate it in `dependencies:` — point
Expand Down Expand Up @@ -250,11 +293,13 @@ valid compose.yml over sections it doesn't need to look at:
does), `command` (shell or exec form), `environment`
(mapping or `KEY=VALUE` array — a mapping value must not be null; host environment pass-through
isn't supported), `ports`/`volumes` (short syntax only — `"host:container"` strings, not
long-syntax mappings), `working_dir`, `user`, `depends_on` (ordering only — a `condition:` other
than `service_started` is rejected, since there's no health-check support). `tty`, `stdin_open`,
and `networks` are accepted but silently ignored: TTY/stdin allocation is already decided per
invocation (see "TTY allocation" below), not fixed per service, and every service already shares
the one project network `compose.project` sets up.
long-syntax mappings), `working_dir`, `user`, `restart` (stored as-is; `no` is the default —
`wip up --watch` is what actually acts on it, see
[Restarting exited dependencies](#restarting-exited-dependencies-wip-up---watch)), `depends_on`
(ordering only — a `condition:` other than `service_started` is rejected, since there's no
health-check support). `tty`, `stdin_open`, and `networks` are accepted but silently ignored:
TTY/stdin allocation is already decided per invocation (see "TTY allocation" below), not fixed
per service, and every service already shares the one project network `compose.project` sets up.
- `wip logs` takes at most one `SERVICE` (defaulting to `compose.service`) — `wslc logs`, like
`docker logs`, follows a single container, unlike a real compose tool's multi-service view.
- `sync:` behaves exactly like `mode: container`'s (falls back to the primary service's own image,
Expand Down Expand Up @@ -422,7 +467,7 @@ to that tag directly — `sync.build`'s tag wins if both are set, so don't confi
| `wip doctor` | Diagnose WSL2, interop, WSLC, config, architecture, and Git |
| `wip config` | Print the effective configuration (secrets masked) |
| `wip build [--no-cache] [-- OPTIONS]` | Build the image from the `build` definition. `wslc build` reuses matching local layers by default; `--no-cache` disables that. |
| `wip up [-d] [--no-sync] [--no-cache]` | Start the primary `dependencies:` entry (`container:` names which one) and its sidecars (creating any that are missing, on `network:` if set). `-d` runs the main container in the background; with `sync:` configured, the source is mirrored into the volume first unless `--no-sync` |
| `wip up [-d] [--no-sync] [--no-cache] [--watch] [--interval N]` | Start the primary `dependencies:` entry (`container:` names which one) and its sidecars (creating any that are missing, on `network:` if set). `-d` runs the main container in the background; with `sync:` configured, the source is mirrored into the volume first unless `--no-sync`. `--watch` polls every `N` seconds (default 5, implies `-d`) and restarts any exited dependency whose `restart:` allows it (not available under mode: compose) |
| `wip stop` | Stop the primary container and its sidecar `dependencies:` without removing them |
| `wip down` | Stop and remove the primary container and its sidecar `dependencies:` |
| `wip exec [--no-interactive] COMMAND...` | Run a command in the existing container |
Expand Down Expand Up @@ -635,7 +680,11 @@ and its current limitations (`run`, and `interaction:` of type `run`/`build`).

Beyond Compose parity, a resident/daemon process, a GUI, PowerShell-specific tuning, direct
registry API/manifest parsing, self-update, and plugins are all unimplemented and not currently
planned. What's still planned for `wip`, roughly in priority order:
planned. (`wip up --watch`'s restart-policy poll loop isn't an exception to this — it's a
foreground, opt-in loop you keep a terminal open for, the same shape as `wip sync --watch`, not a
background service; see
[Restarting exited dependencies](#restarting-exited-dependencies-wip-up---watch).) What's still
planned for `wip`, roughly in priority order:

1. **`wip provision`** — a dip-style one-shot bootstrap hook (build → up deps → install deps →
create/migrate/seed DB) so a new contributor can go from `git clone` to a working environment
Expand Down
97 changes: 90 additions & 7 deletions lib/wip/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,21 +82,41 @@ def build(*extra)
progress&.finish
end

# Real Compose values that trigger a restart when a container's exited; `no` (the default)
# and anything unrecognized both mean "leave it alone." Exact match only — a typo'd value
# like "always-invalid" must stay inert, not accidentally match via a loose prefix check.
AUTO_RESTART_POLICIES = %w[always unless-stopped].freeze
ON_FAILURE_POLICY = /\Aon-failure(?::\d+)?\z/ # optional `:MAX_RETRIES` suffix, e.g. "on-failure:3"

# WSLC's `WslcContainerState` (confirmed via microsoft/WSL's own docs and
# ContainerModel.h — ContainerInformation#State has no custom JSON enum serializer, so
# nlohmann::json emits its raw ordinal): 0 invalid, 1 created, 2 running, 3 exited, 4
# deleted. Unlike Docker, there's no separate "dead" state — only `exited` is a live,
# restartable exit; `deleted` means the container itself is gone (needs `wip up` to
# recreate it, not `start`).
WSLC_CONTAINER_STATE_EXITED = 3

desc 'up', 'Start the configured container and its dependencies, creating them if necessary'
option :detach, type: :boolean, default: false, aliases: '-d'
option :sync, type: :boolean, default: true, desc: 'Mirror the source into the sync volume first (--no-sync skips)'
option :no_cache, type: :boolean, default: false, desc: 'Build compose-native images without cached layers'
option :watch, type: :boolean, default: false, aliases: '-w',
desc: 'Poll dependencies and restart any exited one whose restart: allows it (implies -d)'
option :interval, type: :numeric, default: 5, desc: 'Seconds between --watch polls (default: 5)'
def up
if load_config.compose?
sync_before_boot if options[:sync]
return execute(compose_bridge.up(detach: options[:detach]), interactive: tty?(!options[:detach]))
end
return up_via_compose_bridge if load_config.compose?

# Validated up front, before any startup side effect (image build, network/dependency/
# container creation) — otherwise a bad --interval would only surface as a ConfigError
# after already bringing everything up.
interval = restart_interval if options[:watch]

ensure_compose_images
ensure_network
sidecar_names.each { |name| ensure_dependency(name) }
sync_before_boot if options[:sync]
ensure_container
watch_restarts(interval) if options[:watch]
end

desc 'sync', 'Mirror the source tree into the sync volume'
Expand Down Expand Up @@ -407,16 +427,79 @@ def sync_before_boot
warn "wip: run `wip sync --watch` in another terminal to keep #{settings.target} up to date"
end

def up_via_compose_bridge
if options[:watch]
raise ConfigError, '`wip up --watch` is not supported under mode: compose (wip never parses a ' \
'compose.yml service list in that mode, so there is nothing to poll)'
end

sync_before_boot if options[:sync]
execute(compose_bridge.up(detach: options[:detach]), interactive: tty?(!options[:detach]))
end

def ensure_container
container = load_config.container
interactive = tty?(!options[:detach])
interactive = tty?(!detach?)
if resource_exists?(builder.find)
warn "wip: starting existing container '#{container}'"
execute(builder.start(detach: options[:detach]), interactive: interactive)
execute(builder.start(detach: detach?), interactive: interactive)
else
warn "wip: container '#{container}' not found, creating it"
execute(builder.up(detach: options[:detach]), interactive: interactive)
execute(builder.up(detach: detach?), interactive: interactive)
end
end

# --watch polls in a loop after boot, which can't share this one thread with an attached
# (`-it`) primary container — force the same effective behavior `-d` gives ensure_container.
def detach? = options[:detach] || options[:watch]

# Approximates Docker Compose's `restart:` policy via a foreground poll loop — not a
# background daemon/service (see README "Roadmap"); the same opt-in, keep-a-terminal-open
# shape as `wip sync --watch`.
def watch_restarts(interval)
names = load_config.dependencies.keys
warn "wip: watching #{names.join(', ')} for exited restart: containers every #{interval}s " \
'(running detached; Ctrl-C to stop)'
loop do
names.each { |name| restart_if_exited(name) }
sleep interval
end
rescue Interrupt
warn "\nwip: watch stopped"
end

def restart_interval
raise ConfigError, '--interval must be a positive number' unless options[:interval].positive?

options[:interval]
end

# Status-based, not transition-based: each tick checks current state, not whether it *just*
# exited. Can't distinguish "crashed on its own" from "you ran `wip stop`/`wip down` in
# another terminal" — Ctrl-C this loop first if you're about to do either (see README).
def restart_if_exited(name)
policy = load_config.dependency(name)['restart']
return unless auto_restart?(policy)
return unless container_status(name) == WSLC_CONTAINER_STATE_EXITED

warn "wip: '#{name}' has exited, restarting it (restart: #{policy})"
execute(builder.dependency_start(name), exit_on_failure: false)
end

def auto_restart?(policy) = AUTO_RESTART_POLICIES.include?(policy) || ON_FAILURE_POLICY.match?(policy.to_s)

# Isolated to this one method: if a future wslc release changes this shape, fixing it here
# is a one-line change. Logs the raw entry under --debug so that's immediately visible
# instead of silently no-op'ing forever.
def container_status(name)
code, output = probe(builder.dependency_find(name))
return nil unless code.zero?

entry = JSON.parse(output).first
warn "wip: [debug] '#{name}': #{entry.inspect}" if debug?
entry&.fetch('State', nil)
rescue JSON::ParserError
nil
end
end
end
31 changes: 24 additions & 7 deletions lib/wip/compose_file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@ module Wip
# "Compose mode (native)") — once wslc ships that support, or a
# compose-for-wslc tool reliably supports `run`.
class ComposeFile
Service = Struct.new(:image, :build, :command, :env, :ports, :volumes, :workdir, :user, :depends_on, :profiles,
keyword_init: true)
Service = Struct.new(:image, :build, :command, :env, :ports, :volumes, :workdir, :user, :restart, :depends_on,
:profiles, keyword_init: true)

SERVICE_KEYS = %w[image build command environment ports volumes working_dir user depends_on profiles].freeze
SERVICE_KEYS = %w[image build command environment ports volumes working_dir user restart depends_on
profiles].freeze
# Real Compose keys that read as meaningful here but have nothing to map onto: TTY/stdin
# allocation is decided per invocation (CommandBuilder#tty?), not per service; every
# service already shares one project network (config.rb); and `wslc run`/`exec` has no
# restart-policy or capability flag to forward `restart:`/`cap_add:` to.
IGNORED_SERVICE_KEYS = %w[tty stdin_open networks restart cap_add].freeze
# capability flag to forward `cap_add:` to. (`restart:` used to be here too, silently
# ignored — it's parsed below now and approximated by `wip up --watch`, see cli.rb.)
IGNORED_SERVICE_KEYS = %w[tty stdin_open networks cap_add].freeze
BUILD_KEYS = %w[context dockerfile args shadow_context].freeze
SUPPORTED_CONDITIONS = %w[service_started].freeze
LIST_HINT = 'only supports short syntax ("host:container"), not long-syntax mappings'
Expand Down Expand Up @@ -65,14 +67,14 @@ def build_specs
end.to_h
end

# Shaped like Config::DEPENDENCY_DEFAULTS expects: image/command/env/ports/volumes/workdir,
# Shaped like Config::DEPENDENCY_DEFAULTS expects: image/command/env/ports/volumes/workdir/restart,
# in dependency order so callers iterating sidecars start them before their dependents.
def to_dependencies_hash
startable_order.to_h do |name|
service = @services.fetch(name)
[name, { 'image' => service.build ? image_tag(name, service) : service.image, 'command' => service.command,
'env' => service.env, 'ports' => service.ports, 'volumes' => service.volumes,
'workdir' => service.workdir, 'user' => service.user }]
'workdir' => service.workdir, 'user' => service.user, 'restart' => service.restart }]
end
end

Expand All @@ -97,6 +99,7 @@ def build_service(name, entry)
Service.new(image: image, build: build, command: normalize_command(entry['command']),
env: normalize_env(name, entry['environment']), **normalize_service_lists(name, entry),
workdir: presence(entry['working_dir']), user: presence(entry['user']),
restart: normalize_restart(entry['restart']),
depends_on: normalize_depends_on(name, entry['depends_on']))
end

Expand Down Expand Up @@ -250,6 +253,20 @@ def visit(name, visited, visiting, order)
order << name
end

# Compose's own default (`no`) applies whether restart: is absent or explicitly falsy —
# including the very common unquoted `restart: no`, which YAML resolves to the boolean
# `false`, not the string "no" (confirmed against this repo's own Psych: `YAML.safe_load
# ("restart: no")` => {"restart"=>false}). Every other value is accepted as-is, even ones
# outside always/unless-stopped/on-failure[:N]: this parser's job is to read what's in
# compose.yml, not police it — `wip up --watch` (cli.rb) decides which values it acts on.
# Rejecting a real, valid Compose value here would break projects that already work today
# (compose.yml predates wip, unlike wip.yml itself).
def normalize_restart(value)
return 'no' if value == false

presence(value) || 'no'
end

def presence(value) = value.to_s.empty? ? nil : value.to_s
end
end
Loading
Loading