wslc-wip 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0845dfb67f17323b8a99e6f2f92ef06c17bc03fd1316680cf5f43f2640d73da3'
4
- data.tar.gz: 6eea9bb520aed0fff5b401b71a20dccdade6b50d9a71403037e59e4e5b83ab2e
3
+ metadata.gz: b0fdc63759c329dc9cbc9a60a0ef184db31087d454070871bc3f39a1982d7426
4
+ data.tar.gz: 447d4f3206b3662def93d376b950de99564f3efbfc85e8607bbaad6fb432545d
5
5
  SHA512:
6
- metadata.gz: 1b522d206ba3070244f51bda9d4e144dfe95df63ab01bb92b35d0fe0e84a29035c997d7b061c2ec23286c8bd9f586c69e276b7304abd1c8ad81fe808293599b5
7
- data.tar.gz: d7fc237820edfcc734f55775e027133c5f9877c24b16f5b1ca6aa98304b35fdea5825a179ff7f64b4d22fe2634d52d11761777284b68a86e76faa56432d33230
6
+ metadata.gz: fc07a96116a3c737332a313447663a1730d6d15729c329b6e121a356de2bf62ba696b7386a854c685a7bb2415fc5b7c0edc42437ec86b80b2d13de99b6075cad
7
+ data.tar.gz: 61b8a28cd2953335ca38a97cf15f52302aa070698d183d95fefc1edcd7d85d661cb8461ebe1de830969f4b9480734dfeb13c6431821925a500377eb56b1a4221
data/README.md CHANGED
@@ -97,6 +97,11 @@ commands:
97
97
  type: build
98
98
  context: .
99
99
  tag: slidict/slidict:development
100
+ sync: # optional; mirror the source into a named volume instead of bind-mounting it live
101
+ exclude:
102
+ - .git
103
+ - tmp/
104
+ - node_modules/
100
105
  ```
101
106
 
102
107
  `env` values are stringified. `wip config` masks any key matching token, password, secret,
@@ -129,6 +134,97 @@ the same way Compose's service names resolve. `wip down` tears the main containe
129
134
  dependencies down (the network itself is left in place). Each dependency entry accepts `image`
130
135
  (required), `command`, `env`, `ports`, `volumes`, and `workdir` — the same shape as `defaults`.
131
136
 
137
+ ### Source sync
138
+
139
+ Bind-mounting the app directory (`.:/app`) is what usually makes a container boot crawl under
140
+ wslc — see [Slow boot when the app directory is
141
+ bind-mounted](#slow-boot-when-the-app-directory-is-bind-mounted) for why. A `sync:` block hands
142
+ that problem to `wip`: the source is mounted read-only, the app runs off a named volume, and wip
143
+ mirrors one into the other with `rsync`.
144
+
145
+ ```yaml
146
+ sync:
147
+ source: . # host path, relative to wip.yml (default: the wip.yml directory)
148
+ target: /app # container path served by the volume (default: defaults.workdir, else /app)
149
+ volume: app-src # named volume holding the mirror (default: "<defaults.container>-src")
150
+ mount: /host-src # where the source is bind-mounted read-only (default: /host-src)
151
+ exclude: # rsync --exclude patterns
152
+ - .git
153
+ - tmp/
154
+ - node_modules/
155
+ delete: true # rsync --delete (default: true)
156
+ command: rsync # binary that does the mirroring (default: rsync)
157
+ options: [] # extra flags appended to the rsync invocation
158
+ interval: 2 # seconds between syncs for `wip sync --watch` (default: 2)
159
+ ```
160
+
161
+ Everything below `sync:` is optional — `sync: {}` alone already works. With it in place:
162
+
163
+ - Any `defaults.volumes` entry mounting `target` (the usual `.:/app`) is replaced by
164
+ `<source>:/host-src:ro` plus `app-src:/app`, so the running app only ever touches the volume.
165
+ Other volumes (`bundle:/usr/local/bundle`, ...) are passed through untouched, and
166
+ `dependencies:` keep mounting whatever they declare.
167
+ - `wip up` mirrors the source into the volume before the container boots; `wip up --no-sync`
168
+ skips that step.
169
+ - `wip sync` mirrors on demand — `wslc exec`ing rsync inside the container when it's running, and
170
+ falling back to a throwaway container with the same mounts when it isn't.
171
+ - `wip sync --watch [--interval N]` keeps re-syncing until Ctrl-C, so host edits reach the
172
+ container with a short delay. Run it in a second terminal alongside `wip up -d`.
173
+ - `wip doctor` reports the resolved source, volume, and target, and fails if the source is missing.
174
+
175
+ Like every built-in command, `wip sync` takes precedence over a `commands:` entry of the same
176
+ name; wip says so and points at `wip dispatch sync`, which still runs yours.
177
+
178
+ Two things to keep in mind. The mirror runs `rsync` *inside* the container, so the image needs it
179
+ (`RUN apt-get update && apt-get install -y rsync`) — or point `sync.command` at a copy tool the
180
+ image already has. And the mirror is one-way (host → volume): anything the app writes under
181
+ `target` is removed by the next `--delete` pass unless you `exclude` it, give it its own volume,
182
+ or set `delete: false`.
183
+
184
+ `sync:` is mutually exclusive with `compose:` — in compose mode the compose file owns the volume
185
+ layout.
186
+
187
+ ### Compose mode
188
+
189
+ If your project already has a real `compose.yml`, don't duplicate it in `dependencies:` — point
190
+ `wip` at it instead:
191
+
192
+ ```yaml
193
+ version: 1
194
+ compose:
195
+ service: app # required: which compose service wip run/exec/NAME target
196
+ command: wslc-compose # required: the compose-for-wslc binary/path you have installed
197
+ file: compose.yml # optional; auto-detected next to wip.yml otherwise
198
+ project: myapp # optional; omitted lets the compose tool pick its own default
199
+ ```
200
+
201
+ `compose:` is mutually exclusive with `dependencies:`/`defaults.network` — pick one orchestration
202
+ path per project. In compose mode, `wip` becomes a thin bridge to an external compose-for-`wslc`
203
+ CLI rather than reimplementing Compose itself.
204
+
205
+ `wslc` itself has no native Compose support yet (tracked upstream in
206
+ [microsoft/WSL#40948](https://github.com/microsoft/WSL/issues/40948)), and until it does,
207
+ independent third-party tools fill the gap — for example
208
+ [bacarndiaye/wslc-compose](https://github.com/bacarndiaye/wslc-compose) (Python) and
209
+ [inuyume/wslc-compose](https://github.com/inuyume/wslc-compose) (Go), among others. `wslc` is new
210
+ and still evolving, so expect more of these to show up (and existing ones to change) over time.
211
+ `wip` deliberately doesn't pick a winner or default to any of them (unlike `wslc.command`, which
212
+ defaults to `auto` and searches for `wslc.exe`/`wslc`): `compose.command` is required and treats
213
+ every implementation equally — set it to whichever binary name or absolute path you've installed.
214
+ Whichever one you use needs to understand `-f FILE [-p PROJECT] up|down|exec|logs`, the subset of
215
+ the Compose CLI vocabulary `wip` drives. `wip doctor` reports whether the configured command is
216
+ found, its version, and which compose file `wip` resolved.
217
+
218
+ - `wip up`/`wip down` delegate straight to `<compose command> up -d`/`down`.
219
+ - `wip exec`/`wip NAME` (custom `commands:`) run inside `compose.service`.
220
+ - `wip shell` also goes through the bridge: unless `commands.shell` is defined in `wip.yml`, it
221
+ `exec`s `bash` against `compose.service`, falling back to `sh`.
222
+ - `wip logs [-f] [SERVICE...]` is only available in compose mode.
223
+ - `wip run` has no ephemeral-container equivalent in this exec-only vocabulary, so it falls back
224
+ to `exec` against the already-running `compose.service` (wip warns when this happens).
225
+ - `commands:` entries with `type: run`/`type: build` aren't supported in compose mode — use your
226
+ compose tool's own `build`/`up --build` directly; compose owns builds for its own services.
227
+
132
228
  ## Commands
133
229
 
134
230
  | Command | Description |
@@ -137,11 +233,13 @@ dependencies down (the network itself is left in place). Each dependency entry a
137
233
  | `wip doctor` | Diagnose WSL2, interop, WSLC, config, architecture, and Git |
138
234
  | `wip config` | Print the effective configuration (secrets masked) |
139
235
  | `wip build -- --no-cache` | Build the image from the `build` definition |
140
- | `wip up [-d]` | Start `defaults.container` and its `dependencies` (creating any that are missing, on `defaults.network` if set). `-d` runs the main container in the background |
236
+ | `wip up [-d] [--no-sync]` | Start `defaults.container` and its `dependencies` (creating any that are missing, on `defaults.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` |
141
237
  | `wip down` | Stop and remove `defaults.container` and its `dependencies` |
142
238
  | `wip exec [--no-interactive] COMMAND...` | Run a command in the existing container |
143
- | `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container |
239
+ | `wip run [--no-interactive] COMMAND...` | Run a command in a new `--rm` container (compose mode: `exec`s into `compose.service` instead) |
144
240
  | `wip shell` | Open the configured shell, falling back to `bash` then `sh` |
241
+ | `wip logs [-f] [SERVICE...]` | Follow compose service logs (compose mode only) |
242
+ | `wip sync [-w] [--interval N]` | Mirror the source into the sync volume once, or keep re-syncing with `--watch` (needs `sync:`) |
145
243
  | `wip NAME ARGS...` | Run `commands.NAME`, appending any extra arguments |
146
244
 
147
245
  TTY allocation is decided by combining the command's config, the CLI option, and whether both
@@ -209,6 +307,22 @@ When `pull access denied` (or similar) is detected, wip suggests how to log in:
209
307
  wslc registry login -u <username> docker.io
210
308
  ```
211
309
 
310
+ ### Slow boot when the app directory is bind-mounted
311
+
312
+ wslc containers run in their own VM, so a bind-mounted host directory (`.:/app`) is always shared
313
+ in over virtiofs, even when the host path is already on WSL's native filesystem. Frameworks that
314
+ scan large directory trees at startup (Ruby's Zeitwerk autoloader, for example) issue many small
315
+ per-file stat/open calls, and each one is a round trip through that layer — CPU on the Windows
316
+ side can look busy while almost no data is actually transferred, and the process can appear hung
317
+ for minutes with barely any resource usage to show for it.
318
+
319
+ If a debug log shows a boot-time command "stuck" with low CPU/mem/IO in `resource_monitor`'s
320
+ output, this is worth checking before assuming the app itself is broken. The fix is to stop
321
+ bind-mounting the source live and mirror it into a named volume instead, so the app only ever
322
+ touches fast native storage once it's running. `wip` does that for you — add a `sync:` block and
323
+ it rewrites the mounts, mirrors before boot, and re-syncs on demand. See [Source
324
+ sync](#source-sync).
325
+
212
326
  ### CPU architecture mismatch
213
327
 
214
328
  Check the image and publish a multi-arch (amd64/arm64) image:
@@ -243,10 +357,44 @@ checklist.
243
357
 
244
358
  ## Not in the initial release
245
359
 
246
- Full Compose compatibility (multiple networks, `depends_on` ordering/health checks, profiles), a
247
- resident/daemon process, a GUI, PowerShell-specific tuning, direct registry API/manifest parsing,
248
- self-update, and plugins are all unimplemented. Likely future additions: a richer config schema,
249
- lifecycle hooks, platform selection, and more detailed diagnostics.
360
+ Full Compose compatibility isn't reimplemented in `wip` itself, but is available by delegating to
361
+ a third-party compose-for-`wslc` tool see [Compose mode](#compose-mode). A resident/daemon
362
+ process, a GUI, PowerShell-specific tuning, direct registry API/manifest parsing, self-update, and
363
+ plugins are all unimplemented.
364
+
365
+ ## Roadmap
366
+
367
+ `wip` already covers most of what [`dip`](https://github.com/bibendi/dip) adds on top of Compose —
368
+ named commands (`commands:`), `run`/`exec` hidden behind a single verb, `.env` passthrough, and
369
+ sidecar services via `dependencies:` + `defaults.network`. Fuller Compose semantics
370
+ (`depends_on` ordering/health checks, log aggregation, named volumes, profiles, scaling) are now
371
+ handled by delegating to a separate compose-for-`wslc` tool in [compose mode](#compose-mode)
372
+ rather than being reimplemented in `wip` itself. Which of those actually work is entirely up to
373
+ the external tool you point `compose.command` at — `wip` only forwards
374
+ `-f FILE [-p PROJECT] up|down|exec|logs`, so treat that list as what Compose offers, not as
375
+ something `wip` guarantees. See that section for what compose mode covers
376
+ and its current limitations (`run`, and `commands:` of type `run`/`build`). What's still planned
377
+ for `wip`, roughly in priority order:
378
+
379
+ 1. **`wip provision`** — a dip-style one-shot bootstrap hook (build → up deps → install deps →
380
+ create/migrate/seed DB) so a new contributor can go from `git clone` to a working environment
381
+ in two commands (`wip provision && wip up`).
382
+ 2. **Config file merging** — `--config` currently accepts one file; support layering
383
+ (`wip.yml` + `wip.override.yml`, or a `WIP_CONFIG` list) for dev/CI/debug variants without
384
+ duplicating the whole file, plus a `wip config --resolved` view of the merged result.
385
+ 3. **Bind-mount boot time (`rails c`, `bundle`, ...)** — commands like `wip rails c` still start
386
+ noticeably slower than the equivalent under `docker compose`, mostly from WSL2 bind-mounted
387
+ (`.:/app`-style) volumes doing many small reads for gems/`node_modules` (use `--debug` to
388
+ confirm it's disk I/O and not `wip`'s own overhead). [Source sync](#source-sync) works around
389
+ this today by running the app off a named volume and mirroring the host tree into it, at the
390
+ cost of a one-way sync with a short delay. A tighter loop (host-side file watching instead of
391
+ interval polling, two-way sync) is the natural next step. We're also hoping for improvements on
392
+ the `wslc` side itself (faster bind-mount/cache behavior); `wip` will pick those up for free as
393
+ soon as they land.
394
+
395
+ Each of these should stay additive to the existing `wip.yml` shape — no breaking changes to
396
+ `defaults`, `commands`, `dependencies`, or `compose` are planned. A resident daemon and a GUI
397
+ remain out of scope; see "Not in the initial release" above.
250
398
 
251
399
  ## License
252
400
 
data/lib/wip/cli.rb CHANGED
@@ -4,6 +4,7 @@ require 'thor'
4
4
  require 'yaml'
5
5
  require 'json'
6
6
  require 'stringio'
7
+ require 'shellwords'
7
8
 
8
9
  module Wip
9
10
  # Thor-based command-line interface for wip.
@@ -62,14 +63,41 @@ module Wip
62
63
 
63
64
  desc 'up', 'Start the configured container and its dependencies, creating them if necessary'
64
65
  option :detach, type: :boolean, default: false, aliases: '-d'
66
+ option :sync, type: :boolean, default: true, desc: 'Mirror the source into the sync volume first (--no-sync skips)'
65
67
  def up
68
+ if load_config.compose?
69
+ return execute(compose_bridge.up(detach: options[:detach]), interactive: tty?(!options[:detach]))
70
+ end
71
+
66
72
  ensure_network
67
73
  load_config.dependencies.each_key { |name| ensure_dependency(name) }
74
+ sync_before_boot if options[:sync]
68
75
  ensure_container
69
76
  end
70
77
 
78
+ desc 'sync', 'Mirror the source tree into the sync volume'
79
+ option :watch, type: :boolean, default: false, aliases: '-w', desc: 'Keep re-syncing until interrupted'
80
+ option :interval, type: :numeric, desc: 'Seconds between syncs when watching (default: sync.interval)'
81
+ def sync
82
+ settings = sync_settings!
83
+ warn_shadowed_command('sync')
84
+ return run_sync unless options[:watch]
85
+
86
+ interval = watch_interval(settings)
87
+ warn "wip: syncing #{settings.source} -> #{settings.volume}:#{settings.target} " \
88
+ "every #{interval}s (Ctrl-C to stop)"
89
+ loop do
90
+ run_sync(exit_on_failure: false)
91
+ sleep interval
92
+ end
93
+ rescue Interrupt
94
+ warn "\nwip: sync stopped"
95
+ end
96
+
71
97
  desc 'down', 'Stop and remove the configured container and its dependencies'
72
98
  def down
99
+ return execute(compose_bridge.down, exit_on_failure: false) if load_config.compose?
100
+
73
101
  execute(builder.down, exit_on_failure: false)
74
102
  execute(builder.remove, exit_on_failure: false)
75
103
  load_config.dependencies.each_key do |name|
@@ -81,41 +109,51 @@ module Wip
81
109
  desc 'exec COMMAND...', 'Execute a command in the running container'
82
110
  option :interactive, type: :boolean, default: true
83
111
  def exec(*command)
84
- tty = builder.tty?(options[:interactive])
85
- execute(builder.exec(command, interactive: options[:interactive]), interactive: tty)
112
+ execute(exec_target(command, interactive: options[:interactive]), interactive: tty?(options[:interactive]))
86
113
  end
87
114
 
88
115
  desc 'run COMMAND...', 'Run a command in a new container'
89
116
  option :interactive, type: :boolean, default: true
90
117
  def run_command(*command)
91
- tty = builder.tty?(options[:interactive])
92
- execute(builder.run(command, interactive: options[:interactive]), interactive: tty)
118
+ if load_config.compose?
119
+ warn "wip: compose mode has no ephemeral 'run'; executing in the running " \
120
+ "'#{load_config.compose_service}' service instead"
121
+ return execute(exec_target(command, interactive: options[:interactive]),
122
+ interactive: tty?(options[:interactive]))
123
+ end
124
+
125
+ execute(builder.run(command, interactive: options[:interactive]), interactive: tty?(options[:interactive]))
93
126
  end
94
127
  map 'run' => :run_command
95
128
 
96
129
  desc 'shell', 'Open a shell in the configured container'
97
130
  def shell_command
98
131
  configured = load_config.command('shell')
99
- if configured
100
- tty = builder.tty?(configured.fetch('interactive', false))
101
- return execute(builder.custom('shell', []), interactive: tty)
102
- end
132
+ return dispatch('shell') if configured
103
133
 
104
- tty = builder.tty?(true)
105
- code = execute(builder.exec(['bash'], settings: { 'interactive' => true }, interactive: true),
106
- interactive: tty, exit_on_failure: false)
134
+ code = execute(exec_target(['bash'], interactive: true), interactive: tty?(true), exit_on_failure: false)
107
135
  return if code.zero?
108
136
 
109
- execute(builder.exec(['sh'], settings: { 'interactive' => true }, interactive: true), interactive: tty)
137
+ execute(exec_target(['sh'], interactive: true), interactive: tty?(true))
110
138
  end
111
139
  map 'shell' => :shell_command
112
140
 
141
+ desc 'logs [SERVICE...]', 'Follow logs from compose services (compose mode only)'
142
+ option :follow, type: :boolean, default: true, aliases: '-f'
143
+ def logs(*services)
144
+ raise ConfigError, '`wip logs` is only available in compose mode' unless load_config.compose?
145
+
146
+ execute(compose_bridge.logs(services: services, follow: options[:follow]), interactive: true)
147
+ end
148
+
113
149
  desc 'dispatch COMMAND [ARGS...]', 'Run a command defined in wip.yml'
114
150
  def dispatch(name = nil, *arguments)
115
151
  raise ConfigError, 'A command is required' unless name
116
152
 
117
153
  values = load_config.command(name) || raise(ConfigError, "Unknown command: #{name}")
118
- execute(builder.custom(name, arguments), interactive: builder.tty?(values.fetch('interactive', false)))
154
+ return dispatch_compose(name, values, arguments) if load_config.compose?
155
+
156
+ execute(builder.custom(name, arguments), interactive: tty?(values.fetch('interactive', false)))
119
157
  end
120
158
 
121
159
  private
@@ -134,6 +172,31 @@ module Wip
134
172
  CommandBuilder.new(wslc: resolver.resolve(load_config.wslc_command), config: load_config, dotenv: dotenv)
135
173
  end
136
174
 
175
+ def compose_bridge = @compose_bridge ||= ComposeBridge.for(load_config)
176
+
177
+ def tty?(requested) = requested && Environment.new.interactive?
178
+
179
+ def exec_target(arguments, interactive:)
180
+ if load_config.compose?
181
+ return compose_bridge.exec(load_config.compose_service, arguments,
182
+ interactive: interactive)
183
+ end
184
+
185
+ builder.exec(arguments, interactive: interactive)
186
+ end
187
+
188
+ def dispatch_compose(name, values, arguments)
189
+ type = values['type'] || 'exec'
190
+ unless type == 'exec'
191
+ raise ConfigError, "commands.#{name}: type '#{type}' is not supported in compose mode " \
192
+ '(use `wslc-compose build`/`up --build` directly)'
193
+ end
194
+
195
+ command = Shellwords.split(values['command'].to_s) + arguments
196
+ interactive = values.fetch('interactive', false)
197
+ execute(exec_target(command, interactive: interactive), interactive: tty?(interactive))
198
+ end
199
+
137
200
  def execute(command, interactive: false, exit_on_failure: true)
138
201
  runner = CommandRunner.new(debug: debug?)
139
202
  code = reporter.step("running: #{CommandDisplay.for_debug(command)}", live: !interactive) do
@@ -186,9 +249,49 @@ module Wip
186
249
  end
187
250
  end
188
251
 
252
+ def sync_settings!
253
+ load_config.sync || raise(ConfigError, '`wip sync` needs a sync: block in wip.yml')
254
+ end
255
+
256
+ # `sync.interval` is validated when the config loads; --interval isn't, and
257
+ # a negative one would only surface as an ArgumentError from `sleep`.
258
+ def watch_interval(settings)
259
+ interval = options[:interval] || settings.interval
260
+ raise ConfigError, '--interval must be a positive number' unless interval.positive?
261
+
262
+ interval
263
+ end
264
+
265
+ # A built-in command wins over a `commands:` entry of the same name, so
266
+ # point at `wip dispatch` rather than letting the custom one vanish.
267
+ def warn_shadowed_command(name)
268
+ return unless load_config.commands.key?(name)
269
+
270
+ warn "wip: commands.#{name} in wip.yml is shadowed by the built-in `wip #{name}`; " \
271
+ "run it with `wip dispatch #{name}`"
272
+ end
273
+
274
+ # Inside the running container the mirror is a plain `exec`; otherwise it
275
+ # takes a throwaway container that mounts the same source and volume.
276
+ def run_sync(exit_on_failure: true)
277
+ command = container_running? ? builder.sync_exec : builder.sync_run
278
+ execute(command, exit_on_failure: exit_on_failure)
279
+ end
280
+
281
+ def container_running? = resource_exists?(builder.find_running)
282
+
283
+ def sync_before_boot
284
+ settings = load_config.sync
285
+ return unless settings
286
+
287
+ warn "wip: syncing #{settings.source} -> #{settings.volume}:#{settings.target}"
288
+ execute(builder.sync_run)
289
+ warn "wip: run `wip sync --watch` in another terminal to keep #{settings.target} up to date"
290
+ end
291
+
189
292
  def ensure_container
190
293
  container = load_config.defaults['container']
191
- interactive = builder.tty?(!options[:detach])
294
+ interactive = tty?(!options[:detach])
192
295
  if resource_exists?(builder.find)
193
296
  warn "wip: starting existing container '#{container}'"
194
297
  execute(builder.start(detach: options[:detach]), interactive: interactive)
@@ -49,6 +49,26 @@ module Wip
49
49
  [@wslc, 'list', '--all', '--filter', "name=#{container}", '--format', 'json']
50
50
  end
51
51
 
52
+ def find_running
53
+ container = required(@config.defaults, 'container')
54
+ [@wslc, 'list', '--filter', "name=#{container}", '--format', 'json']
55
+ end
56
+
57
+ # Mirrors into the volume from a throwaway container, for when the app
58
+ # container isn't running yet (or at all) — e.g. just before `up` boots it.
59
+ def sync_run
60
+ sync = required_sync
61
+ command = [@wslc, 'run', '--rm']
62
+ sync.volume_specs.each { |spec| command.push('-v', spec) }
63
+ command.push(required(@config.defaults, 'image')).concat(sync.mirror_command)
64
+ end
65
+
66
+ # Mirrors from inside the running container, which already has both the
67
+ # read-only source mount and the volume attached.
68
+ def sync_exec
69
+ [@wslc, 'exec', required(@config.defaults, 'container'), *required_sync.mirror_command]
70
+ end
71
+
52
72
  def down
53
73
  [@wslc, 'stop', required(@config.defaults, 'container')]
54
74
  end
@@ -70,7 +90,7 @@ module Wip
70
90
  command = [@wslc, 'run', '--name', name.to_s]
71
91
  command.push('--network', @config.network) if @config.network
72
92
  command << '-d' if detach
73
- command.concat(options(values)).push(required(values, 'image'))
93
+ command.concat(options(values, sync: false)).push(required(values, 'image'))
74
94
  command.concat(Shellwords.split(values['command'].to_s)) unless values['command'].to_s.empty?
75
95
  command
76
96
  end
@@ -113,18 +133,29 @@ module Wip
113
133
 
114
134
  private
115
135
 
116
- def options(values, include_container: false, include_publish: true)
136
+ def options(values, include_container: false, include_publish: true, sync: true)
117
137
  result = []
118
138
  result.push('-w', values['workdir']) unless values['workdir'].to_s.empty?
119
139
  merged_env(values).each { |key, value| result.push('-e', "#{key}=#{value}") }
120
140
  if include_publish
121
141
  Array(values['ports']).each { |port| result.push('-p', port.to_s) }
122
- Array(values['volumes']).each { |volume| result.push('-v', volume.to_s) }
142
+ volume_specs(values, sync: sync).each { |volume| result.push('-v', volume) }
123
143
  end
124
144
  result << required(values, 'container') if include_container
125
145
  result
126
146
  end
127
147
 
148
+ # With sync configured, a live bind mount of the target (`.:/app`) is
149
+ # swapped for the read-only source mount plus the named volume, so the
150
+ # running app only ever touches the volume.
151
+ def volume_specs(values, sync: true)
152
+ specs = Array(values['volumes']).map(&:to_s)
153
+ settings = sync ? @config.sync : nil
154
+ return specs unless settings
155
+
156
+ specs.reject { |spec| settings.replaces?(spec) } + settings.volume_specs
157
+ end
158
+
128
159
  # .env supplies defaults; env set in wip.yml (defaults or per-command) wins on conflict.
129
160
  def merged_env(values) = @dotenv.merge(values.fetch('env', {}))
130
161
 
@@ -142,6 +173,10 @@ module Wip
142
173
  network
143
174
  end
144
175
 
176
+ def required_sync
177
+ @config.sync || raise(ConfigError, 'No sync: block configured in wip.yml')
178
+ end
179
+
145
180
  def dependency_values(name)
146
181
  @config.dependency(name) || raise(ConfigError, "Unknown dependency: #{name}")
147
182
  end
@@ -1,20 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wip
4
- # Locates the wslc/wslc.exe executable on the current system.
4
+ # Locates an executable on the current system, trying a list of candidates.
5
5
  class CommandResolver
6
6
  CANDIDATES = ['wslc.exe', 'wslc', '/mnt/c/Windows/System32/wslc.exe'].freeze
7
+ DEFAULT_INSTALL_HINT = <<~HINT.chomp
8
+ Install or update the WSL container tooling, then run:
7
9
 
8
- def initialize(path: ENV.fetch('PATH', ''), executable: nil)
10
+ wip doctor
11
+ HINT
12
+
13
+ def initialize(path: ENV.fetch('PATH', ''), executable: nil, candidates: CANDIDATES, label: 'WSLC',
14
+ install_hint: DEFAULT_INSTALL_HINT)
9
15
  @path = path
10
16
  @executable = executable || method(:executable?)
17
+ @candidates = candidates
18
+ @label = label
19
+ @install_hint = install_hint
11
20
  end
12
21
 
13
22
  def resolve(configured = 'auto')
14
23
  return configured if configured != 'auto' && @executable.call(configured)
15
- return raise_not_found if configured != 'auto'
24
+ return raise_not_found([configured]) if configured != 'auto'
16
25
 
17
- CANDIDATES.find { |candidate| @executable.call(candidate) } || raise_not_found
26
+ @candidates.find { |candidate| @executable.call(candidate) } || raise_not_found(@candidates)
18
27
  end
19
28
 
20
29
  private
@@ -25,16 +34,14 @@ module Wip
25
34
  @path.split(File::PATH_SEPARATOR).any? { |directory| File.executable?(File.join(directory, command)) }
26
35
  end
27
36
 
28
- def raise_not_found
37
+ def raise_not_found(attempted)
29
38
  raise CommandNotFoundError, <<~MESSAGE
30
- WSLC was not found.
39
+ #{@label} was not found.
31
40
 
32
41
  Checked:
33
- #{CANDIDATES.join("\n ")}
34
-
35
- Install or update the WSL container tooling, then run:
42
+ #{attempted.join("\n ")}
36
43
 
37
- wip doctor
44
+ #{@install_hint}
38
45
  MESSAGE
39
46
  end
40
47
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wip
4
+ # Builds argument arrays for wslc-compose invocations, delegating orchestration
5
+ # to a real compose.yml instead of wip's own dependencies:/network handling.
6
+ class ComposeBridge
7
+ FILENAMES = %w[compose.yml compose.yaml docker-compose.yml docker-compose.yaml].freeze
8
+ # No default candidates: wip doesn't favor any one compose-for-wslc implementation.
9
+ # compose.command must name the one you've installed.
10
+ INSTALL_HINT = <<~HINT.chomp
11
+ wip doesn't bundle or pin a compose-for-wslc implementation — install one and set
12
+ compose.command in wip.yml to its binary name or path, e.g.:
13
+
14
+ https://github.com/bacarndiaye/wslc-compose
15
+ https://github.com/inuyume/wslc-compose
16
+ HINT
17
+
18
+ def self.for(config, resolver: CommandResolver.new(candidates: [], label: 'compose command',
19
+ install_hint: INSTALL_HINT))
20
+ new(compose_command: resolver.resolve(config.compose_command), file: file_path(config),
21
+ project: config.compose_project)
22
+ end
23
+
24
+ def self.file_path(config)
25
+ base = Pathname(config.path).dirname
26
+ configured = config.compose_file
27
+ # Relative compose.file is resolved against wip.yml, not the current directory,
28
+ # so `wip` behaves the same from any subdirectory (matching auto-detection below).
29
+ return base.join(configured).expand_path if configured
30
+
31
+ FILENAMES.map { |name| base.join(name) }.find(&:file?) ||
32
+ raise(ConfigError, "compose mode: no compose file found next to #{config.path} " \
33
+ "(looked for #{FILENAMES.join(', ')})")
34
+ end
35
+
36
+ def initialize(compose_command:, file:, project: nil)
37
+ @compose_command = compose_command
38
+ @file = file
39
+ @project = project
40
+ end
41
+
42
+ def up(detach: true)
43
+ command = base.push('up')
44
+ command << '-d' if detach
45
+ command
46
+ end
47
+
48
+ def down = base.push('down')
49
+
50
+ def exec(service, arguments, interactive: true)
51
+ command = base.push('exec')
52
+ command << '-T' unless interactive
53
+ command.push(service.to_s).concat(arguments)
54
+ end
55
+
56
+ def logs(services: [], follow: true)
57
+ command = base.push('logs')
58
+ command << '-f' if follow
59
+ command.concat(services.map(&:to_s))
60
+ end
61
+
62
+ private
63
+
64
+ def base
65
+ command = [@compose_command, '-f', @file.to_s]
66
+ command.push('-p', @project) if @project
67
+ command
68
+ end
69
+ end
70
+ end
data/lib/wip/config.rb CHANGED
@@ -20,6 +20,19 @@ module Wip
20
20
  def up_command = @raw.dig('up', 'command')
21
21
  def dependencies = @raw['dependencies'] || {}
22
22
  def network = defaults['network']
23
+ def compose = @raw['compose']
24
+ def compose? = !!compose
25
+ def compose_service = compose && compose['service']
26
+ def compose_file = compose && compose['file']
27
+ def compose_project = compose && compose['project']
28
+ def compose_command = compose && compose['command']
29
+ def sync? = !!sync
30
+
31
+ def sync
32
+ return @sync if defined?(@sync)
33
+
34
+ @sync = @raw.key?('sync') ? build_sync : nil
35
+ end
23
36
 
24
37
  def command(name)
25
38
  entry = commands[name.to_s]
@@ -37,7 +50,8 @@ module Wip
37
50
 
38
51
  def to_h(redact: true)
39
52
  value = { 'version' => 1, 'wslc' => { 'command' => wslc_command }, 'defaults' => defaults,
40
- 'up' => { 'command' => up_command }, 'dependencies' => dependencies,
53
+ 'up' => { 'command' => up_command }, 'dependencies' => dependencies, 'compose' => compose,
54
+ 'sync' => sync&.to_h,
41
55
  'commands' => commands.transform_values { |entry| defaults.merge('type' => 'exec').merge(entry) } }
42
56
  redact ? redact_secrets(value) : value
43
57
  end
@@ -50,6 +64,18 @@ module Wip
50
64
 
51
65
  validate_commands!
52
66
  validate_dependencies!
67
+ validate_compose!
68
+ validate_sync!
69
+ end
70
+
71
+ def build_sync
72
+ SyncSettings.new(@raw['sync'], base: path && File.dirname(path.to_s),
73
+ workdir: defaults['workdir'], container: defaults['container'])
74
+ end
75
+
76
+ def validate_sync!
77
+ return unless sync
78
+ raise ConfigError, 'sync is mutually exclusive with compose' if compose?
53
79
  end
54
80
 
55
81
  def validate_commands!
@@ -64,6 +90,15 @@ module Wip
64
90
  dependencies.each { |name, entry| validate_dependency!(name, entry) }
65
91
  end
66
92
 
93
+ def validate_compose!
94
+ return unless @raw.key?('compose')
95
+ raise ConfigError, 'compose must be a mapping' unless compose.is_a?(Hash)
96
+ raise ConfigError, 'compose.service must not be empty' if compose_service.to_s.empty?
97
+ raise ConfigError, 'compose.command must not be empty' if compose_command.to_s.empty?
98
+ raise ConfigError, 'compose is mutually exclusive with dependencies' if dependencies.any?
99
+ raise ConfigError, 'compose is mutually exclusive with defaults.network' if network
100
+ end
101
+
67
102
  def validate_command!(name, entry)
68
103
  raise ConfigError, "commands.#{name} must be a mapping" unless entry.is_a?(Hash)
69
104
 
data/lib/wip/doctor.rb CHANGED
@@ -7,10 +7,13 @@ module Wip
7
7
  class Doctor
8
8
  Result = Data.define(:level, :message)
9
9
 
10
- def initialize(loader:, resolver: CommandResolver.new, environment: Environment.new)
10
+ def initialize(loader:, resolver: CommandResolver.new, environment: Environment.new,
11
+ compose_resolver: CommandResolver.new(candidates: [], label: 'compose command',
12
+ install_hint: ComposeBridge::INSTALL_HINT))
11
13
  @loader = loader
12
14
  @resolver = resolver
13
15
  @environment = environment
16
+ @compose_resolver = compose_resolver
14
17
  end
15
18
 
16
19
  def call
@@ -18,9 +21,7 @@ module Wip
18
21
  results << result(@environment.wsl2? ? :ok : :fail, *wsl2_messages)
19
22
  results << interop_result unless @environment.windows?
20
23
  results << Result.new(:ok, "Architecture: #{@environment.architecture}")
21
- config = load_config(results)
22
- command = resolve(config, results) if config
23
- check_version(command, results) if command
24
+ check_config(load_config(results), results)
24
25
  results << result(command_available?('git') ? :ok : :warn, 'Git is available',
25
26
  'Git is not available to the WSLC build environment')
26
27
  results
@@ -41,7 +42,7 @@ module Wip
41
42
 
42
43
  def load_config(results)
43
44
  config = @loader.load
44
- invalid = %w[container image].select { |key| config.defaults[key].to_s.empty? }
45
+ invalid = config.compose? ? [] : %w[container image].select { |key| config.defaults[key].to_s.empty? }
45
46
  results << Result.new(invalid.empty? ? :ok : :fail,
46
47
  invalid.empty? ? 'Loaded wip.yml' : "Empty defaults: #{invalid.join(', ')}")
47
48
  config
@@ -50,6 +51,19 @@ module Wip
50
51
  nil
51
52
  end
52
53
 
54
+ def check_config(config, results)
55
+ return unless config
56
+
57
+ check_wslc(config, results)
58
+ check_compose(config, results) if config.compose?
59
+ check_sync(config, results) if config.sync?
60
+ end
61
+
62
+ def check_wslc(config, results)
63
+ command = resolve(config, results)
64
+ check_version(command, results) if command
65
+ end
66
+
53
67
  def resolve(config, results)
54
68
  command = @resolver.resolve(config.wslc_command)
55
69
  results << Result.new(:ok, "Found #{command}")
@@ -59,12 +73,42 @@ module Wip
59
73
  nil
60
74
  end
61
75
 
62
- def check_version(command, results)
76
+ def check_version(command, results, label: 'WSLC')
63
77
  _output, status = Open3.capture2e(command, 'version')
64
78
  results << Result.new(status.success? ? :ok : :fail,
65
- status.success? ? 'WSLC is available' : 'WSLC version failed')
79
+ status.success? ? "#{label} is available" : "#{label} version failed")
66
80
  rescue Errno::ENOENT
67
- results << Result.new(:fail, 'WSLC version failed')
81
+ results << Result.new(:fail, "#{label} version failed")
82
+ end
83
+
84
+ def check_compose(config, results)
85
+ command = resolve_compose(config, results)
86
+ check_version(command, results, label: 'compose command') if command
87
+ check_compose_file(config, results)
88
+ end
89
+
90
+ def resolve_compose(config, results)
91
+ command = @compose_resolver.resolve(config.compose_command)
92
+ results << Result.new(:ok, "Found #{command}")
93
+ command
94
+ rescue CommandNotFoundError => e
95
+ results << Result.new(:fail, e.message)
96
+ nil
97
+ end
98
+
99
+ def check_compose_file(config, results)
100
+ path = ComposeBridge.file_path(config)
101
+ results << result(path.file? ? :ok : :fail, "Found compose file #{path}",
102
+ "Compose file not found: #{path}")
103
+ rescue ConfigError => e
104
+ results << Result.new(:fail, e.message)
105
+ end
106
+
107
+ def check_sync(config, results)
108
+ sync = config.sync
109
+ results << result(File.directory?(sync.source) ? :ok : :fail,
110
+ "Sync source #{sync.source} mirrors into volume #{sync.volume} at #{sync.target}",
111
+ "Sync source not found: #{sync.source}")
68
112
  end
69
113
 
70
114
  def result(condition, ok_message, fail_message)
@@ -3,6 +3,14 @@
3
3
  module Wip
4
4
  # Translates raw WSLC error output into friendlier hints.
5
5
  class ErrorInterpreter
6
+ # Shells report a missing rsync as "rsync: not found", while the container
7
+ # runtime names the executable either before or after its own phrasing.
8
+ RSYNC_MISSING = Regexp.union(
9
+ /rsync: (?:command )?not found/i,
10
+ /rsync[^\n]*executable file not found/i,
11
+ /executable file not found[^\n]*rsync/i
12
+ )
13
+
6
14
  def initialize(architecture: Environment.new.architecture)
7
15
  @architecture = architecture
8
16
  end
@@ -11,11 +19,24 @@ module Wip
11
19
  case output
12
20
  when /pull access denied|insufficient_scope|authorization failed/i then registry_message
13
21
  when %r{no matching manifest for linux/(?:amd64|arm64)}i then architecture_message
22
+ when RSYNC_MISSING then rsync_message
14
23
  end
15
24
  end
16
25
 
17
26
  private
18
27
 
28
+ def rsync_message
29
+ <<~TEXT
30
+ `wip sync` needs rsync inside the image.
31
+
32
+ Install it in your Dockerfile:
33
+
34
+ RUN apt-get update && apt-get install -y rsync
35
+
36
+ Or point sync.command at a tool the image already has.
37
+ TEXT
38
+ end
39
+
19
40
  def registry_message
20
41
  <<~TEXT
21
42
  The container registry rejected the request.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Wip
6
+ # Validated access to the `sync:` block, which mirrors the host source tree
7
+ # into a named volume instead of bind-mounting it live.
8
+ #
9
+ # A bind-mounted app directory is shared into the container's VM over
10
+ # virtiofs, so every stat/open a boot-time directory scan makes is a round
11
+ # trip. Mirroring the tree into a named volume (native storage inside the VM)
12
+ # and re-running the mirror on demand keeps the edit-on-the-host workflow
13
+ # while leaving the running app on fast disk.
14
+ class SyncSettings
15
+ DEFAULT_MOUNT = '/host-src'
16
+ DEFAULT_TARGET = '/app'
17
+ DEFAULT_BINARY = 'rsync'
18
+ DEFAULT_INTERVAL = 2
19
+ # -a preserves modes and timestamps so file watchers and bundler see a
20
+ # faithful copy rather than a tree that looks freshly written every sync.
21
+ BASE_OPTIONS = %w[-a].freeze
22
+ # Trailing mount options wslc/docker accept after the container path.
23
+ VOLUME_MODES = %w[ro rw z Z cached delegated consistent].freeze
24
+
25
+ attr_reader :target, :mount, :volume, :exclude, :binary, :extra_options, :interval
26
+
27
+ def initialize(raw, base: nil, workdir: nil, container: nil)
28
+ raise ConfigError, 'sync must be a mapping' unless raw.is_a?(Hash)
29
+
30
+ @base = base
31
+ assign_paths(raw, workdir: workdir, container: container)
32
+ assign_mirror(raw)
33
+ validate!
34
+ end
35
+
36
+ def delete? = !!@delete
37
+
38
+ # Expanded against the wip.yml directory so the mirror covers the same tree
39
+ # no matter which subdirectory wip was invoked from.
40
+ def source
41
+ @source ||= @base ? Pathname(@base).join(@raw_source).expand_path.to_s : @raw_source
42
+ end
43
+
44
+ # What `-v` specs the main container needs: the source read-only, and the
45
+ # named volume where the app actually runs.
46
+ def volume_specs = ["#{source}:#{mount}:ro", "#{volume}:#{target}"]
47
+
48
+ # True for a configured volume that sync replaces, so `.:/app` in
49
+ # `defaults.volumes` quietly becomes the read-only mount plus the volume.
50
+ def replaces?(spec)
51
+ [target.chomp('/'), mount.chomp('/')].include?(container_path(spec))
52
+ end
53
+
54
+ # Trailing slashes matter to rsync: they copy the *contents* of the mount
55
+ # into the target rather than nesting it one directory deeper.
56
+ def mirror_command
57
+ command = [binary, *BASE_OPTIONS]
58
+ command << '--delete' if delete?
59
+ command.concat(exclude.map { |pattern| "--exclude=#{pattern}" })
60
+ command.concat(extra_options)
61
+ command.push("#{mount.chomp('/')}/", "#{target.chomp('/')}/")
62
+ end
63
+
64
+ def to_h
65
+ { 'source' => source, 'target' => target, 'mount' => mount, 'volume' => volume,
66
+ 'delete' => delete?, 'exclude' => exclude, 'command' => binary, 'options' => extra_options,
67
+ 'interval' => interval }
68
+ end
69
+
70
+ private
71
+
72
+ def assign_paths(raw, workdir:, container:)
73
+ @raw_source = presence(raw['source']) || '.'
74
+ @target = presence(raw['target']) || presence(workdir) || DEFAULT_TARGET
75
+ @mount = presence(raw['mount']) || DEFAULT_MOUNT
76
+ @volume = presence(raw['volume']) || "#{presence(container) || 'wip'}-src"
77
+ end
78
+
79
+ def assign_mirror(raw)
80
+ @delete = raw.fetch('delete', true)
81
+ @exclude = Array(raw['exclude']).map(&:to_s)
82
+ @binary = presence(raw['command']) || DEFAULT_BINARY
83
+ @extra_options = Array(raw['options']).map(&:to_s)
84
+ @interval = raw.key?('interval') ? raw['interval'] : DEFAULT_INTERVAL
85
+ end
86
+
87
+ def validate!
88
+ raise ConfigError, 'sync.target must be an absolute path' unless @target.start_with?('/')
89
+ raise ConfigError, 'sync.mount must be an absolute path' unless @mount.start_with?('/')
90
+ raise ConfigError, 'sync.mount must differ from sync.target' if @mount.chomp('/') == @target.chomp('/')
91
+ return if @interval.is_a?(Numeric) && @interval.positive?
92
+
93
+ raise ConfigError, 'sync.interval must be a positive number'
94
+ end
95
+
96
+ def container_path(spec)
97
+ parts = spec.to_s.split(':')
98
+ parts.pop if parts.size > 2 && VOLUME_MODES.include?(parts.last)
99
+ parts.last.to_s.chomp('/')
100
+ end
101
+
102
+ def presence(value) = value.to_s.empty? ? nil : value.to_s
103
+ end
104
+ end
data/lib/wip/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wip
4
- VERSION = '0.5.0'
4
+ VERSION = '0.7.0'
5
5
  end
data/lib/wip.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'wip/version'
4
4
  require_relative 'wip/errors'
5
+ require_relative 'wip/sync_settings'
5
6
  require_relative 'wip/config'
6
7
  require_relative 'wip/config_loader'
7
8
  require_relative 'wip/environment'
@@ -11,6 +12,7 @@ require_relative 'wip/build_context'
11
12
  require_relative 'wip/command_resolver'
12
13
  require_relative 'wip/error_interpreter'
13
14
  require_relative 'wip/command_builder'
15
+ require_relative 'wip/compose_bridge'
14
16
  require_relative 'wip/command_display'
15
17
  require_relative 'wip/command_runner'
16
18
  require_relative 'wip/resource_monitor'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wslc-wip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wip contributors
@@ -39,6 +39,7 @@ files:
39
39
  - lib/wip/command_display.rb
40
40
  - lib/wip/command_resolver.rb
41
41
  - lib/wip/command_runner.rb
42
+ - lib/wip/compose_bridge.rb
42
43
  - lib/wip/config.rb
43
44
  - lib/wip/config_loader.rb
44
45
  - lib/wip/debug_reporter.rb
@@ -49,6 +50,7 @@ files:
49
50
  - lib/wip/error_interpreter.rb
50
51
  - lib/wip/errors.rb
51
52
  - lib/wip/resource_monitor.rb
53
+ - lib/wip/sync_settings.rb
52
54
  - lib/wip/version.rb
53
55
  homepage: https://wslc-wip.slidict.com/
54
56
  licenses: